#!/usr/bin/env python3 """Build and package the Jellyfin plugin described by build.yaml. Produces the same layout as JPRM (jellyfin-plugin-repository-manager): a zip containing the artifacts listed in build.yaml plus a meta.json, named ``_.zip``. Usage: scripts/build_plugin.py --version 1.0.4.0 --changelog "..." --output dist """ import argparse import datetime import hashlib import json import os import re import shutil import subprocess import sys import zipfile from pathlib import Path import yaml REPO_ROOT = Path(__file__).resolve().parent.parent BUILD_YAML = REPO_ROOT / "build.yaml" PROJECT = REPO_ROOT / "Jellyfin.Plugin.OidcAuth" / "Jellyfin.Plugin.OidcAuth.csproj" def normalize_version(version): """Pad a version to Jellyfin's four-part form (1.0.4 -> 1.0.4.0).""" parts = version.strip().lstrip("v").split(".") if not 1 <= len(parts) <= 4: raise ValueError(f"'{version}' is not a 1- to 4-part version") for part in parts: if not part.isdigit(): raise ValueError(f"'{version}' has a non-numeric component '{part}'") parts += ["0"] * (4 - len(parts)) return ".".join(parts) def slugify(name): """'OIDC Auth' -> 'oidc-auth'.""" return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", name.lower())).strip("-") def load_build_config(): with BUILD_YAML.open(encoding="utf-8") as handle: return yaml.safe_load(handle) def sync_version(version): """Write the release version back into build.yaml and the csproj.""" build_yaml = BUILD_YAML.read_text(encoding="utf-8") build_yaml = re.sub( r'^version:\s*.*$', f'version: "{version}"', build_yaml, count=1, flags=re.MULTILINE ) BUILD_YAML.write_text(build_yaml, encoding="utf-8") csproj = PROJECT.read_text(encoding="utf-8") for tag in ("Version", "AssemblyVersion", "FileVersion"): csproj = re.sub( rf"<{tag}>[^<]*", f"<{tag}>{version}", csproj, count=1 ) PROJECT.write_text(csproj, encoding="utf-8") def publish(cfg, version, configuration, publish_dir): if publish_dir.exists(): shutil.rmtree(publish_dir) command = [ "dotnet", "publish", str(PROJECT), "--configuration", configuration, "--framework", cfg["framework"], "--output", str(publish_dir), f"-p:Version={version}", f"-p:AssemblyVersion={version}", f"-p:FileVersion={version}", # Plugin DLLs ship loose; a nuspec-style layout would confuse Jellyfin. "-p:DebugType=none", "-p:GenerateDocumentationFile=false", ] print("+ " + " ".join(command), flush=True) subprocess.run(command, check=True, cwd=REPO_ROOT) def build_metadata(cfg, version, changelog): """The meta.json Jellyfin reads out of the plugin folder.""" return { "guid": cfg["guid"], "name": cfg["name"], "description": cfg["description"].strip(), "overview": cfg["overview"].strip(), "owner": cfg["owner"], "category": cfg["category"], "version": version, "changelog": changelog.strip(), "targetAbi": cfg["targetAbi"], "timestamp": datetime.datetime.now(datetime.timezone.utc) .replace(tzinfo=None) .isoformat(timespec="seconds") + "Z", } def package(cfg, version, changelog, publish_dir, output_dir): output_dir.mkdir(parents=True, exist_ok=True) zip_path = output_dir / f"{slugify(cfg['name'])}_{version}.zip" missing = [a for a in cfg["artifacts"] if not (publish_dir / a).is_file()] if missing: raise SystemExit( f"error: build.yaml lists artifacts missing from {publish_dir}: {', '.join(missing)}" ) metadata = build_metadata(cfg, version, changelog) with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive: for artifact in cfg["artifacts"]: archive.write(publish_dir / artifact, artifact) archive.writestr("meta.json", json.dumps(metadata, indent=4) + "\n") return zip_path def md5sum(path): digest = hashlib.md5() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def emit_outputs(**values): """Expose results to the workflow via $GITHUB_OUTPUT.""" target = os.environ.get("GITHUB_OUTPUT") if not target: return with open(target, "a", encoding="utf-8") as handle: for key, value in values.items(): handle.write(f"{key}={value}\n") def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--version", help="release version; defaults to build.yaml") parser.add_argument("--changelog", default="", help="changelog for this version") parser.add_argument("--configuration", default="Release") parser.add_argument("--output", default="dist", help="directory for the zip") parser.add_argument( "--sync-only", action="store_true", help="only write --version into build.yaml and the csproj, then exit", ) args = parser.parse_args() cfg = load_build_config() version = normalize_version(args.version or cfg["version"]) changelog = args.changelog or cfg.get("changelog", "") if args.sync_only: sync_version(version) print(f"build.yaml and csproj set to {version}") emit_outputs(version=version) return publish_dir = REPO_ROOT / "artifacts" / "publish" publish(cfg, version, args.configuration, publish_dir) zip_path = package(cfg, version, changelog, publish_dir, Path(args.output).resolve()) checksum = md5sum(zip_path) print(f"\npackaged {zip_path}") print(f"version {version}") print(f"md5 {checksum}") emit_outputs( version=version, checksum=checksum, zip_path=zip_path, zip_name=zip_path.name, ) if __name__ == "__main__": sys.exit(main())