Compare commits
4
Commits
603884c5a2
..
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fc20c5578 | ||
|
|
550131d43b | ||
|
|
f2fc6f657f | ||
|
|
1477c240a1 |
@@ -43,6 +43,15 @@ jobs:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: uv run scripts/release.py
|
||||
|
||||
# Wheel/sdist are platform-independent; the linux job also pushes them
|
||||
# to PyPI. Token is the PYPI_TOKEN repository secret.
|
||||
- name: Publish to PyPI
|
||||
if: matrix.os == 'linux'
|
||||
shell: ${{ matrix.shell }}
|
||||
env:
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||
run: uv publish
|
||||
|
||||
- name: Attach platform artifact to the Gitea release
|
||||
if: matrix.os != 'linux'
|
||||
shell: ${{ matrix.shell }}
|
||||
|
||||
@@ -4,13 +4,24 @@
|
||||
|
||||
Netflix style browsing of your local media archive. Supports keyboard, mouse and gamepad navigation. Uses your favorite movie player.
|
||||
|
||||
**[Windows, Mac and Linux downloads](https://git.zi.fi/LeoVasanko/mediahive/releases)**
|
||||
## Downloads
|
||||
|
||||
## Getting Started
|
||||
- **Windows**: [Installer](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-win64-setup.exe) · [Portable ZIP](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-win64-portable.zip)
|
||||
- **macOS**: [Installer](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-macos.pkg)
|
||||
- **Linux**: [AppImage](https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-linux.AppImage)
|
||||
|
||||
- Windows: Download `*-win64-setup.exe` from the releases page and run it (no admin needed; auto-updates included). A `-win64-portable.zip` is also available.
|
||||
- macOS: Download `*-macos-setup.pkg` and install (auto-updates included).
|
||||
- Linux: Download the `.AppImage`, `chmod +x` it, and run. Alternatively install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run directly with `uvx --from mediahive[gui] mediahive`.
|
||||
### Linux
|
||||
|
||||
```
|
||||
wget https://git.zi.fi/LeoVasanko/mediahive/releases/download/latest/MediaHive-linux.AppImage
|
||||
chmod +x MediaHive-linux.AppImage && ./MediaHive-linux.AppImage
|
||||
```
|
||||
|
||||
You may also run without installing via
|
||||
|
||||
```
|
||||
uvx --from mediahive[gui] mediahive
|
||||
```
|
||||
|
||||
## What It Does
|
||||
|
||||
@@ -20,7 +31,7 @@ Netflix style browsing of your local media archive. Supports keyboard, mouse and
|
||||
- Remembers per-episode playback positions and offers series continue points
|
||||
- Hand off playback to your preferred system player
|
||||
|
||||
Windows and macOS builds are currently portable-only (no installer). On first startup, the app asks for your media folder, which you can later change using the in-app folder icon.
|
||||
On first startup, the app asks for your media folder, which you can later change using the in-app folder icon.
|
||||
|
||||
Note that `.mediahive` folder is created in your media folder to hold all the metadata and preview clips, avoiding the lengthy processing that you will see on initial startup.
|
||||
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ All media paths are scoped to a **root**, identified by a friendly `root_id`
|
||||
- `GET /api/assets/{root_id}/{asset_type}/{asset_path:path}` is constrained to `<root>/.mediahive/{asset_type}` where `asset_type` is one of `movies`, `series`, `people`.
|
||||
- `GET /api/meta/{root_id}/{meta_key}` supports metadata keys currently limited to `playback-state` and `scanignore`.
|
||||
- `GET /api/player/status` returns `{ "remote": true|false }`.
|
||||
- Roots may also be provided at startup via the `MEDIAHIVE_ROOTS` environment variable (JSON dict of name → path), which overrides the persisted configuration.
|
||||
- Roots may also be provided at startup via CLI arguments (`mediahive /path/to/media ...`), which are passed to the server through fastapi-vue's env config (`mediahive.config.config`) and override the persisted configuration.
|
||||
- Assumed playback: after `POST /api/play/{root_id}` the launched item is assumed to be playing while the frontend reports no input activity. On the next `POST /api/activity` the guessed position (`resume base + elapsed`, capped at the TMDb runtime) is written once; watches under 5 minutes are discarded (a peek is not progress). A resume entry written by another tracker (e.g. the GUI's MPC-BE tracker) during the session overrides the guess. The MPC-BE tracker likewise ignores sessions shorter than 5 minutes.
|
||||
|
||||
## WebSocket
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
* - Disables Vite's screen clearing on startup
|
||||
*
|
||||
* Options:
|
||||
* paths - Array of paths to proxy (default: ["/api"])
|
||||
* paths - Array of paths to proxy (default: ['/api'])
|
||||
*/
|
||||
|
||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || "http://localhost:8421"
|
||||
export default function fastapiVue({ paths = ['/api'] } = {}) {
|
||||
const backendUrl = process.env.MEDIAHIVE_BACKEND_URL || 'http://localhost:8421'
|
||||
|
||||
// Build proxy configuration for each path
|
||||
const proxy = {}
|
||||
@@ -25,12 +25,12 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
}
|
||||
|
||||
return {
|
||||
name: "vite-plugin-fastapi-mediahive",
|
||||
name: 'vite-plugin-fastapi-mediahive',
|
||||
config: () => ({
|
||||
clearScreen: false,
|
||||
server: { proxy },
|
||||
build: {
|
||||
outDir: "../mediahive/frontend-build",
|
||||
outDir: '../mediahive/frontend-build',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
}),
|
||||
|
||||
+17
-7
@@ -1,16 +1,20 @@
|
||||
"""MediaHive CLI entrypoint."""
|
||||
|
||||
import os
|
||||
|
||||
# Must be set before fastapi_vue env bindings are created (mediahive.config).
|
||||
os.environ["FASTAPI_VUE"] = "MEDIAHIVE"
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi_vue import server
|
||||
from fastapi_vue import env, server
|
||||
|
||||
from mediahive.config import config
|
||||
|
||||
DEFAULT_PORT = 8420
|
||||
DEVMODE = os.getenv("MEDIAHIVE_DEV") == "1"
|
||||
|
||||
|
||||
def _configure_windows_event_loop_policy() -> None:
|
||||
@@ -140,10 +144,11 @@ def main() -> None:
|
||||
name = f"{base_name}{suffix}"
|
||||
suffix += 1
|
||||
roots[name] = p.as_posix()
|
||||
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(roots)
|
||||
# Teleported to the server process by fastapi-vue's server.run().
|
||||
config.roots = roots
|
||||
|
||||
if (
|
||||
DEVMODE
|
||||
env.dev
|
||||
and sys.platform == "win32"
|
||||
and os.environ.get("MEDIAHIVE_DEV_CHILD") != "1"
|
||||
):
|
||||
@@ -156,7 +161,12 @@ def main() -> None:
|
||||
default_port=DEFAULT_PORT,
|
||||
server_header=False,
|
||||
loop="none" if sys.platform == "win32" else "auto",
|
||||
reload=Path(__file__).parent if DEVMODE and sys.platform != "win32" else False,
|
||||
reload=Path(__file__).parent if env.dev and sys.platform != "win32" else False,
|
||||
# fastapi-vue routes the root logger at INFO in dev / WARNING in prod;
|
||||
# keep our own loggers visible in production too.
|
||||
log_config={
|
||||
"loggers": {"mediahive": {"level": "DEBUG" if env.dev else "INFO"}}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
+8
-16
@@ -13,14 +13,20 @@ from pathlib import Path
|
||||
|
||||
import msgspec
|
||||
import msgspec.toml
|
||||
from fastapi_vue import env
|
||||
from platformdirs import user_config_path, user_log_path
|
||||
|
||||
|
||||
class Config(msgspec.Struct, omit_defaults=True):
|
||||
media_folder: str | None = None
|
||||
roots: dict[str, str] | None = None
|
||||
|
||||
|
||||
# Runtime config shared between the CLI entrypoint and the server process via
|
||||
# fastapi-vue's env teleport (MEDIAHIVE_CONFIG). Values set here take
|
||||
# precedence over the persisted config file.
|
||||
config = env(Config)
|
||||
|
||||
|
||||
def config_dir() -> Path:
|
||||
# appauthor=False: avoid the doubled %LOCALAPPDATA%\mediahive\mediahive.
|
||||
# roaming=False: config is machine-specific state, not something to sync
|
||||
@@ -37,25 +43,11 @@ def config_path() -> Path:
|
||||
return config_dir() / "config.toml"
|
||||
|
||||
|
||||
def _migrate_legacy_media_folder(cfg: Config) -> Config:
|
||||
"""If roots is empty but media_folder exists, seed roots with it."""
|
||||
if cfg.roots:
|
||||
return cfg
|
||||
if not cfg.media_folder:
|
||||
return cfg
|
||||
path = Path(cfg.media_folder)
|
||||
name = path.name or path.anchor.strip("/\\").lower() or "media"
|
||||
# Resolve collisions simply by using the basename; if user had weird layout
|
||||
# they can rename via the UI later.
|
||||
return msgspec.structs.replace(cfg, roots={name: cfg.media_folder})
|
||||
|
||||
|
||||
def load_config() -> Config:
|
||||
path = config_path()
|
||||
if path.exists():
|
||||
try:
|
||||
cfg = msgspec.toml.decode(path.read_bytes(), type=Config)
|
||||
return _migrate_legacy_media_folder(cfg)
|
||||
return msgspec.toml.decode(path.read_bytes(), type=Config)
|
||||
except OSError, msgspec.DecodeError, msgspec.ValidationError:
|
||||
return Config()
|
||||
return Config()
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"""Hivescan CLI entrypoint."""
|
||||
|
||||
import os
|
||||
|
||||
# Must be set before fastapi_vue env bindings are created (mediahive.config).
|
||||
os.environ.setdefault("FASTAPI_VUE", "MEDIAHIVE")
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from mediahive.config import config
|
||||
|
||||
|
||||
def _configure_windows_event_loop_policy() -> None:
|
||||
"""Ensure Windows uses Proactor loop so asyncio subprocess APIs are available."""
|
||||
@@ -55,11 +60,9 @@ The server exposes a unified endpoint:
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Defer filesystem validation to the server; pass raw path via env.
|
||||
# Defer filesystem validation to the server; pass raw path via env config.
|
||||
media_root = Path(args.media_folder).expanduser()
|
||||
os.environ["MEDIAHIVE_ROOTS"] = json.dumps({
|
||||
media_root.name or "media": media_root.as_posix()
|
||||
})
|
||||
config.roots = {media_root.name or "media": media_root.as_posix()}
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
|
||||
+10
-20
@@ -37,10 +37,9 @@ from fastapi.responses import (
|
||||
Response,
|
||||
StreamingResponse,
|
||||
)
|
||||
from fastapi_vue import Frontend
|
||||
from fastapi_vue import Frontend, env
|
||||
|
||||
from mediahive.__main__ import DEVMODE
|
||||
from mediahive.config import load_config, log_dir
|
||||
from mediahive.config import config, load_config, log_dir
|
||||
from mediahive.hivescan.images import close_image_client
|
||||
from mediahive.hivescan.scanner import RootScanner
|
||||
from mediahive.hivescan.tmdb_client import close_http_client
|
||||
@@ -974,23 +973,14 @@ async def _activate_all_roots() -> None:
|
||||
"""
|
||||
desired: dict[str, str] = {}
|
||||
|
||||
# 1. CLI roots via MEDIAHIVE_ROOTS (JSON dict)
|
||||
env_roots_raw = os.environ.get("MEDIAHIVE_ROOTS")
|
||||
env_roots: dict[str, str] | None = None
|
||||
if env_roots_raw:
|
||||
try:
|
||||
parsed = json.loads(env_roots_raw)
|
||||
if isinstance(parsed, dict):
|
||||
env_roots = parsed
|
||||
except Exception:
|
||||
logger.exception("Failed to parse MEDIAHIVE_ROOTS")
|
||||
|
||||
# 1. CLI roots (teleported via fastapi-vue's env config) take precedence
|
||||
# 2. Persisted config roots (used only when CLI roots are not provided)
|
||||
cfg = load_config()
|
||||
if env_roots is not None:
|
||||
desired.update(env_roots)
|
||||
elif cfg.roots:
|
||||
desired.update(cfg.roots)
|
||||
if config.roots:
|
||||
desired.update(config.roots)
|
||||
else:
|
||||
cfg = load_config()
|
||||
if cfg.roots:
|
||||
desired.update(cfg.roots)
|
||||
|
||||
if not desired:
|
||||
logger.info("No roots configured; waiting for PUT /api/config/roots")
|
||||
@@ -1056,7 +1046,7 @@ async def lifespan(_app: FastAPI):
|
||||
await close_image_client()
|
||||
|
||||
|
||||
app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=DEVMODE)
|
||||
app = FastAPI(title="MediaHive Server", lifespan=lifespan, debug=env.dev)
|
||||
|
||||
# Allow CORS for development
|
||||
app.add_middleware(
|
||||
|
||||
+18
-10
@@ -24,15 +24,20 @@ import urllib.request
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
# Must be set before fastapi_vue env bindings are created (mediahive.config);
|
||||
# this module is the PyInstaller entry point and may run without __main__.
|
||||
os.environ.setdefault("FASTAPI_VUE", "MEDIAHIVE")
|
||||
|
||||
import msgspec.structs
|
||||
import uvicorn
|
||||
import velopack
|
||||
import webview
|
||||
from fastapi_vue import env
|
||||
from fastapi_vue.logging import patch_log_config
|
||||
from fastapi_vue.startupbox import print_box
|
||||
from tracerite.html import html_traceback
|
||||
|
||||
from mediahive.config import load_config, log_dir, save_config
|
||||
from mediahive.config import config, load_config, log_dir, save_config
|
||||
from mediahive.volume_control import get_volume, set_volume, volume_max
|
||||
|
||||
logger = logging.getLogger("mediahive.winmain")
|
||||
@@ -1202,10 +1207,6 @@ def winmain() -> None:
|
||||
initial_roots[name] = p.as_posix()
|
||||
elif cfg.roots:
|
||||
initial_roots = cfg.roots
|
||||
elif cfg.media_folder:
|
||||
p = _normalize_media_root_input(cfg.media_folder)
|
||||
name = p.name or "media"
|
||||
initial_roots[name] = p.as_posix()
|
||||
|
||||
if not initial_roots:
|
||||
folder = _run_initial_setup()
|
||||
@@ -1219,8 +1220,9 @@ def winmain() -> None:
|
||||
if cfg.roots != initial_roots:
|
||||
save_config(msgspec.structs.replace(cfg, roots=initial_roots))
|
||||
|
||||
# Pass roots to the server via env (validation deferred to server startup)
|
||||
os.environ["MEDIAHIVE_ROOTS"] = json.dumps(initial_roots)
|
||||
# Pass roots to the in-process server via the shared env config
|
||||
# (validation deferred to server startup)
|
||||
config.roots = initial_roots
|
||||
|
||||
backend_port = _reserve_backend_port()
|
||||
backend_url = f"http://{BACKEND_HOST}:{backend_port}"
|
||||
@@ -1238,7 +1240,13 @@ def winmain() -> None:
|
||||
# log config wires up its access-log middleware, emoji level prefixes and
|
||||
# tracerite tracebacks (colors are auto-disabled when stderr is not a tty,
|
||||
# e.g. redirected to the log file in frozen builds).
|
||||
config = uvicorn.Config(
|
||||
log_config = patch_log_config(uvicorn.config.LOGGING_CONFIG)
|
||||
# fastapi-vue routes the root logger at INFO in dev / WARNING in prod;
|
||||
# keep our own loggers visible in production too.
|
||||
log_config.setdefault("loggers", {})["mediahive"] = {
|
||||
"level": "DEBUG" if env.dev else "INFO"
|
||||
}
|
||||
uvicorn_config = uvicorn.Config(
|
||||
"mediahive.server:app",
|
||||
host=BACKEND_HOST,
|
||||
port=backend_port,
|
||||
@@ -1246,9 +1254,9 @@ def winmain() -> None:
|
||||
server_header=False,
|
||||
timeout_graceful_shutdown=0,
|
||||
access_log=False, # fastapi-vue's middleware replaces uvicorn's
|
||||
log_config=patch_log_config(uvicorn.config.LOGGING_CONFIG),
|
||||
log_config=log_config,
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
server = uvicorn.Server(uvicorn_config)
|
||||
backend_thread = threading.Thread(
|
||||
target=server.run, daemon=True, name="mediahive-backend"
|
||||
)
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ dependencies = [
|
||||
"aiofiles>=25.1.0",
|
||||
"aiopathlib>=0.6.0",
|
||||
"bencodepy>=0.9.5",
|
||||
"fastapi-vue>=1.4.1",
|
||||
"fastapi-vue~=1.7.2",
|
||||
"fastapi[standard]>=0.128.0",
|
||||
"httpx[http2]>=0.28.1",
|
||||
"msgspec>=0.19",
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
import tracerite
|
||||
@@ -48,11 +48,11 @@ async def run_devserver(
|
||||
os.environ["MEDIAHIVE_DEV"] = "1"
|
||||
|
||||
async with ProcessGroup() as pg:
|
||||
pg.create_task(check_ports_free(viteurl, backurl))
|
||||
npm_i = await pg.spawn(*npm_install, cwd=front)
|
||||
await check_ports_free(viteurl, backurl)
|
||||
await pg.spawn(*mediahive, *(extra_args or []))
|
||||
await pg.spawn(*mediahive, *(extra_args or []), vital=True)
|
||||
await pg.wait(npm_i, ready(backurl, path=HEALTH))
|
||||
await pg.spawn(*vite, cwd=front)
|
||||
await pg.spawn(*vite, cwd=front, vital=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -75,8 +75,12 @@ def main() -> None:
|
||||
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
||||
)
|
||||
args, extra_args = parser.parse_known_args()
|
||||
with suppress(KeyboardInterrupt):
|
||||
try:
|
||||
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
|
||||
except* KeyboardInterrupt:
|
||||
pass # user stopped the devserver: normal exit
|
||||
except* subprocess.SubprocessError, RuntimeError:
|
||||
raise SystemExit(1) from None # logged in devutil already; exit 1
|
||||
|
||||
|
||||
HELP_EPILOG = """
|
||||
|
||||
@@ -10,20 +10,27 @@ from pathlib import Path
|
||||
MIN_NODE_VERSION = 20
|
||||
|
||||
|
||||
class _PrefixFormatter(logging.Formatter):
|
||||
"""Formatter that adds prefix based on log level."""
|
||||
class _Formatter(logging.Formatter):
|
||||
"""Prefix formatter, intentionally different from fastapi_vue.logging.
|
||||
|
||||
INFO and below pass through unprefixed so messages can use their own
|
||||
markings (>>>, ###); WARNING and above get an emoji prefix.
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
if record.levelno >= logging.ERROR:
|
||||
return f"🛑 {record.getMessage()}"
|
||||
if record.levelno >= logging.WARNING:
|
||||
return f"⚠️ {record.getMessage()}"
|
||||
return f"💣 {record.getMessage()}"
|
||||
return record.getMessage()
|
||||
|
||||
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(_PrefixFormatter())
|
||||
_handler.setFormatter(_Formatter())
|
||||
logger = logging.getLogger("fastapi-vue")
|
||||
logger.addHandler(_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False # own handler; do not double-print via a configured root
|
||||
|
||||
|
||||
def _check_node_version(node_path: str) -> None:
|
||||
|
||||
@@ -1,110 +1,89 @@
|
||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
from asyncio.subprocess import Process
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Self
|
||||
from subprocess import CalledProcessError
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from buildutil import find_dev_tool, find_install_tool, logger
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Coroutine
|
||||
from collections.abc import Awaitable
|
||||
|
||||
|
||||
class ProcessGroup:
|
||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||
class ProcessGroup(asyncio.TaskGroup):
|
||||
"""TaskGroup with structured ownership of async subprocesses."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize empty process tracking."""
|
||||
self._procs: list[asyncio.subprocess.Process] = []
|
||||
self._cmds: dict[int, str] = {} # pid -> command name
|
||||
def __init__(self, *, terminate_timeout: float = 10) -> None:
|
||||
"""Set the grace period before terminate() escalates to kill()."""
|
||||
super().__init__()
|
||||
self._terminate_timeout = terminate_timeout
|
||||
self._cmds: dict[Process, tuple[str, ...]] = {}
|
||||
|
||||
async def spawn(
|
||||
self,
|
||||
*cmd: str,
|
||||
cwd: str | None = None,
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Spawn a subprocess and track it."""
|
||||
cmd_name = Path(cmd[0]).stem
|
||||
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||
self._procs.append(proc)
|
||||
self._cmds[proc.pid] = cmd_name
|
||||
return proc
|
||||
self, *cmd: str, cwd: str | None = None, vital: bool = False
|
||||
) -> Process:
|
||||
"""Spawn and own a subprocess. If a vital process exits, the group cancels."""
|
||||
|
||||
async def wait(
|
||||
self,
|
||||
*waitables: asyncio.subprocess.Process | Coroutine[Any, Any, Any],
|
||||
) -> None:
|
||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
||||
async def run() -> None:
|
||||
name = Path(cmd[0]).stem
|
||||
logger.info(">>> %s", " ".join([name, *cmd[1:]]))
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||
self._cmds[proc] = cmd
|
||||
started.set_result(proc)
|
||||
except Exception as e: # ruff: ignore[blind-except]
|
||||
started.set_exception(e)
|
||||
return
|
||||
|
||||
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
|
||||
returncode = await proc.wait()
|
||||
if returncode != 0:
|
||||
cmd_name = self._cmds.get(proc.pid, "unknown")
|
||||
raise subprocess.CalledProcessError(returncode, cmd_name)
|
||||
|
||||
tasks = [
|
||||
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
|
||||
for w in waitables
|
||||
]
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Enter the async context manager."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
|
||||
"""Wait for one process to exit, terminate others, then wait for all."""
|
||||
await self._cleanup(immediate=exc_type is not None)
|
||||
|
||||
async def _cleanup(self, *, immediate: bool = False) -> None:
|
||||
running = [p for p in self._procs if p.returncode is None]
|
||||
if not running:
|
||||
return
|
||||
|
||||
if not immediate:
|
||||
# Wait for any one process to exit
|
||||
with suppress(asyncio.CancelledError):
|
||||
await asyncio.wait(
|
||||
[asyncio.create_task(p.wait()) for p in running],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
# Terminate remaining processes
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
try:
|
||||
returncode = await proc.wait()
|
||||
finally:
|
||||
with suppress(ProcessLookupError):
|
||||
p.terminate()
|
||||
|
||||
# Wait for all to finish (with overall timeout), shielded from cancellation
|
||||
still_running = [p for p in self._procs if p.returncode is None]
|
||||
if still_running:
|
||||
with suppress(asyncio.CancelledError):
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.shield(
|
||||
asyncio.wait_for(
|
||||
asyncio.gather(*[p.wait() for p in still_running]),
|
||||
timeout=10,
|
||||
),
|
||||
)
|
||||
await asyncio.wait_for(proc.wait(), self._terminate_timeout)
|
||||
except TimeoutError:
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
with suppress(ProcessLookupError):
|
||||
p.kill()
|
||||
await p.wait()
|
||||
with suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
|
||||
if vital:
|
||||
logger.warning("Vital process %s exited", name)
|
||||
raise CalledProcessError(returncode, cmd)
|
||||
|
||||
started = asyncio.get_running_loop().create_future()
|
||||
self.create_task(run())
|
||||
return await asyncio.shield(started)
|
||||
|
||||
async def wait(self, *waitables: Process | Awaitable) -> tuple[Any, ...]:
|
||||
"""Wait concurrently and return results in argument order."""
|
||||
|
||||
async def task(w: Process | Awaitable) -> Any:
|
||||
if not isinstance(w, Process):
|
||||
return await w
|
||||
if retcode := await w.wait():
|
||||
cmd = self._cmds[w]
|
||||
logger.warning(
|
||||
"Process %s exited with status %d", Path(cmd[0]).stem, retcode
|
||||
)
|
||||
raise CalledProcessError(retcode, cmd)
|
||||
return retcode
|
||||
|
||||
async with asyncio.TaskGroup() as group:
|
||||
tasks = [group.create_task(task(w)) for w in waitables]
|
||||
|
||||
return tuple(task.result() for task in tasks)
|
||||
|
||||
|
||||
async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109
|
||||
async def http_get_server(url: str, timeout: float) -> str | None: # ruff: ignore[async-function-with-timeout]
|
||||
"""GET url with plain asyncio streams, return the response Server header.
|
||||
|
||||
Returns an empty string when the server responds without a Server header,
|
||||
@@ -127,42 +106,43 @@ async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYN
|
||||
writer.close()
|
||||
except OSError, EOFError, ValueError, TimeoutError:
|
||||
return None
|
||||
for line in data.decode("latin-1").split("\r\n"):
|
||||
for line in data.decode(errors="replace").split("\r\n"):
|
||||
if line.lower().startswith("server:"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
return line[7:].strip()
|
||||
return ""
|
||||
|
||||
|
||||
async def check_ports_free(*urls: str) -> None:
|
||||
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
||||
"""Verify URLs are not responding (ports are free).
|
||||
|
||||
async def check(url: str) -> None:
|
||||
server = await http_get_server(url, timeout=0.1)
|
||||
Meant to run as a task inside a TaskGroup. Logs the conflict and raises
|
||||
RuntimeError (handled like a failed process) if any URL responds.
|
||||
"""
|
||||
servers = await asyncio.gather(*(http_get_server(url, timeout=0.1) for url in urls))
|
||||
for url, server in zip(urls, servers, strict=True):
|
||||
if server is not None:
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"Conflicting %s already running at %s", server or "server", url
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
await asyncio.gather(*[check(url) for url in urls])
|
||||
raise RuntimeError(url)
|
||||
|
||||
|
||||
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
||||
"""Wait for the server to be ready by polling an endpoint.
|
||||
|
||||
Use empty path to disable the check and make this return immediately.
|
||||
Raises SystemExit(1) if server doesn't start in time.
|
||||
Logs, then raises RuntimeError if the server doesn't start in time.
|
||||
"""
|
||||
if not path:
|
||||
return
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
|
||||
logger.info("✓ Backend ready!")
|
||||
logger.info("🟢 Backend ready!")
|
||||
return
|
||||
if attempt == max_attempts - 1:
|
||||
logger.warning("Backend didn't start in time")
|
||||
raise SystemExit(1)
|
||||
logger.error("Backend at %s didn't start in time", url)
|
||||
raise RuntimeError(url)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
|
||||
+9
-6
@@ -88,9 +88,12 @@ def _platform() -> _Platform:
|
||||
return _Platform("linux", "linux", "linux-x64", "MediaHive", "mediahive.png", "MediaHive", ".AppImage")
|
||||
|
||||
|
||||
def setup_artifact_name(version: str) -> str:
|
||||
def setup_artifact_name() -> str:
|
||||
"""Versionless name so releases/download/latest/<name> links stay valid."""
|
||||
p = _platform()
|
||||
return f"MediaHive-{version}-{p.tag}-setup{p.setup_ext}"
|
||||
# Windows keeps the -setup suffix: a bare .exe isn't self-explanatory.
|
||||
suffix = "-setup" if sys.platform == "win32" else ""
|
||||
return f"MediaHive-{p.tag}{suffix}{p.setup_ext}"
|
||||
|
||||
|
||||
def fetch_ffmpeg() -> Path:
|
||||
@@ -381,7 +384,7 @@ def build_velopack(version: str) -> Path:
|
||||
raise RuntimeError(f"vpk produced no *{plat.setup_ext} in {releases_dir}")
|
||||
if sys.platform == "darwin":
|
||||
force_macos_user_install(setup)
|
||||
artifact = _REPO_ROOT / "build" / setup_artifact_name(version)
|
||||
artifact = _REPO_ROOT / "build" / setup_artifact_name()
|
||||
artifact.unlink(missing_ok=True)
|
||||
setup.rename(artifact)
|
||||
rename_feed_package(releases_dir, version, plat.channel)
|
||||
@@ -509,7 +512,7 @@ def build_executable() -> None:
|
||||
raise RuntimeError(f"PyInstaller failed with exit code {result.returncode}")
|
||||
|
||||
|
||||
def create_portable_zip(version: str) -> Path:
|
||||
def create_portable_zip() -> Path:
|
||||
"""Create the Windows portable ZIP of the build/MediaHive folder.
|
||||
|
||||
Velopack-less plain-folder distribution for users who cannot or do not
|
||||
@@ -520,7 +523,7 @@ def create_portable_zip(version: str) -> Path:
|
||||
if not dist_folder.exists():
|
||||
raise FileNotFoundError(f"Distribution folder not found: {dist_folder}")
|
||||
|
||||
zip_path = _REPO_ROOT / "build" / f"MediaHive-{version}-win64-portable.zip"
|
||||
zip_path = _REPO_ROOT / "build" / "MediaHive-win64-portable.zip"
|
||||
print(f"Creating {zip_path}...")
|
||||
shutil.make_archive(
|
||||
str(zip_path.with_suffix("")), # removes .zip so make_archive can add it
|
||||
@@ -553,7 +556,7 @@ def main() -> None:
|
||||
|
||||
artifacts = [build_velopack(version)]
|
||||
if sys.platform == "win32":
|
||||
artifacts.append(create_portable_zip(version))
|
||||
artifacts.append(create_portable_zip())
|
||||
|
||||
for artifact_path in artifacts:
|
||||
print(f"✓ Built successfully: {artifact_path}")
|
||||
|
||||
+38
-38
@@ -9,8 +9,9 @@ Reads from [project.urls] Repository in pyproject.toml.
|
||||
Token: GITEA_TOKEN environment variable
|
||||
|
||||
Steps:
|
||||
1. Find clean-versioned platform artifacts in build/ and matching dist/ wheels/sdists
|
||||
2. Abort if any dist files are missing for a found artifact version
|
||||
1. Read the clean tag version via setuptools_scm, find platform artifacts
|
||||
in build/ and matching dist/ wheels/sdists
|
||||
2. Abort if any dist files are missing
|
||||
3. Create a Gitea release for each version (or reuse the existing one
|
||||
for the tag, skipping already-uploaded assets) and upload all assets
|
||||
4. Remind the user to run: uv publish
|
||||
@@ -28,6 +29,7 @@ from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import setuptools_scm
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
@@ -67,23 +69,27 @@ def load_token() -> str:
|
||||
# ZIP + dist helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Matches MediaHive-1.2.3-win64-portable.zip, MediaHive-1.2.3-win64-setup.exe,
|
||||
# MediaHive-1.2.3-macos-setup.pkg, MediaHive-1.2.3-linux-setup.AppImage, etc.
|
||||
# Rejects dev/dirty versions like MediaHive-1.2.3.dev0+gabcd-win64-portable.zip
|
||||
_CLEAN_ARTIFACT_RE = re.compile(
|
||||
r"^MediaHive-(\d+(?:\.\d+)*)-([A-Za-z0-9._-]+)\.(?:zip|dmg|exe|pkg|AppImage)$"
|
||||
)
|
||||
# Installer artifacts are versionless (MediaHive-win64-setup.exe,
|
||||
# MediaHive-macos-setup.pkg, MediaHive-linux-setup.AppImage,
|
||||
# MediaHive-win64-portable.zip) so /releases/download/latest/<name> links
|
||||
# stay valid. The version comes from setuptools_scm instead.
|
||||
_ARTIFACT_RE = re.compile(r"^MediaHive-(?!\d)[A-Za-z0-9._-]+\.(?:zip|dmg|exe|pkg|AppImage)$")
|
||||
|
||||
|
||||
def find_releasable_artifacts() -> list[tuple[Path, str, str]]:
|
||||
"""Return (path, version, platform_tag) for clean-versioned artifacts in build/."""
|
||||
def read_version() -> str:
|
||||
"""Read version via setuptools_scm, refusing dev/dirty versions."""
|
||||
version = setuptools_scm.get_version(root=str(REPO_ROOT))
|
||||
if not re.fullmatch(r"\d+(?:\.\d+)*", version):
|
||||
raise RuntimeError(
|
||||
f"Refusing to release non-clean version {version!r}. Tag a release first."
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
def find_releasable_artifacts() -> list[Path]:
|
||||
"""Return platform artifact paths in build/."""
|
||||
build_dir = REPO_ROOT / "build"
|
||||
results = []
|
||||
for p in sorted(build_dir.glob("MediaHive-*")):
|
||||
m = _CLEAN_ARTIFACT_RE.match(p.name)
|
||||
if m:
|
||||
results.append((p, m.group(1), m.group(2)))
|
||||
return results
|
||||
return [p for p in sorted(build_dir.glob("MediaHive-*")) if _ARTIFACT_RE.match(p.name)]
|
||||
|
||||
|
||||
def find_dist_files(version: str) -> list[Path]:
|
||||
@@ -244,46 +250,40 @@ def main() -> None:
|
||||
try:
|
||||
cfg = load_gitea_config()
|
||||
token = load_token()
|
||||
version = read_version()
|
||||
|
||||
artifacts = find_releasable_artifacts()
|
||||
if not artifacts:
|
||||
print(
|
||||
"No clean-versioned platform artifacts found in build/.\n"
|
||||
"No platform artifacts found in build/.\n"
|
||||
"Run scripts/guibuild.py first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Validate all dist files exist before touching Gitea
|
||||
dist_files: dict[str, list[Path]] = {}
|
||||
if not args.no_dist:
|
||||
for _, version, _platform_tag in artifacts:
|
||||
dist_files[version] = find_dist_files(version)
|
||||
dist_files: list[Path] = [] if args.no_dist else find_dist_files(version)
|
||||
|
||||
base_url = cfg["url"].rstrip("/")
|
||||
repo = cfg["repo"]
|
||||
|
||||
with httpx.Client(headers=gitea_headers(token)) as client:
|
||||
releases: dict[str, tuple[int, set[str]]] = {}
|
||||
for artifact_path, version, platform_tag in artifacts:
|
||||
print(f"\nReleasing {version} ...")
|
||||
tag = f"v{version}"
|
||||
if version not in releases:
|
||||
releases[version] = create_release(
|
||||
client, base_url, repo, tag, version, args.notes, args.draft
|
||||
)
|
||||
release_id, uploaded = releases[version]
|
||||
for path in dist_files.get(version, []):
|
||||
if path.name in uploaded:
|
||||
print(f"Skipping {path.name}, already on the release.")
|
||||
continue
|
||||
upload_asset(client, base_url, repo, release_id, path)
|
||||
print(f"\nReleasing {version} ...")
|
||||
tag = f"v{version}"
|
||||
release_id, uploaded = create_release(
|
||||
client, base_url, repo, tag, version, args.notes, args.draft
|
||||
)
|
||||
for path in dist_files:
|
||||
if path.name in uploaded:
|
||||
print(f"Skipping {path.name}, already on the release.")
|
||||
continue
|
||||
upload_asset(client, base_url, repo, release_id, path)
|
||||
|
||||
release_id, uploaded = releases[version]
|
||||
for artifact_path in artifacts:
|
||||
if artifact_path.name in uploaded:
|
||||
print(f"Skipping {artifact_path.name}, already on the release.")
|
||||
continue
|
||||
print(f"Uploading platform artifact: {platform_tag}")
|
||||
print(f"Uploading platform artifact: {artifact_path.name}")
|
||||
upload_asset(client, base_url, repo, release_id, artifact_path)
|
||||
uploaded.add(artifact_path.name)
|
||||
for feed_file in find_velopack_feed_files():
|
||||
@@ -292,7 +292,7 @@ def main() -> None:
|
||||
continue
|
||||
upload_asset(client, base_url, repo, release_id, feed_file)
|
||||
uploaded.add(feed_file.name)
|
||||
print(f" ✓ {tag} published")
|
||||
print(f" ✓ {tag} published")
|
||||
|
||||
print("\nDone. To publish to PyPI, run:")
|
||||
print(" uv publish")
|
||||
|
||||
Reference in New Issue
Block a user