Initial commit: AVWatch v1.0.0 with .gitignore and CLAUDE.md
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
"""Blackmagic Design.
|
||||
|
||||
BMD publishes their entire support catalogue as one JSON document, which makes
|
||||
them by far the best-behaved vendor in this fleet. One fetch resolves ATEM
|
||||
switchers and panels, Desktop Video (DeckLink), HyperDeck, the converter
|
||||
family and MultiView.
|
||||
|
||||
Verified structure of an entry:
|
||||
|
||||
{
|
||||
"name": "ATEM Switchers 10.4.1 Update",
|
||||
"date": "Last Thursday", # human, relative -- unusable
|
||||
"numericDate": 1788418810000, # ms epoch -- use this
|
||||
"desc": "This software update provides ...",
|
||||
"releaseNotesTitle": "Blackmagic ATEM Switchers 10.4.1",
|
||||
"relatedFamilies": ["atem-live-production-switchers"],
|
||||
"platforms": ["Mac OS X", "Windows"],
|
||||
"type": "Product Release",
|
||||
"urls": {"Windows": [{"major": 10, "minor": 4, "releaseNum": 1,
|
||||
"buildNum": 0, "beta": 255, "downloadId": "..."}]}
|
||||
}
|
||||
|
||||
``beta: 255`` marks a final release; anything lower is a beta build.
|
||||
|
||||
Inventory usage:
|
||||
|
||||
source:
|
||||
kind: blackmagic
|
||||
family: "ATEM Switchers" # matches the leading words of `name`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..models import Item, VersionCheck
|
||||
from ..versions import parse
|
||||
from .base import Context, error
|
||||
|
||||
DOWNLOADS_URL = "https://www.blackmagicdesign.com/api/support/us/downloads.json"
|
||||
|
||||
# Family label -> regex capturing the version out of the `name` field.
|
||||
# Verified against the live feed on 2026-09-08.
|
||||
FAMILY_PATTERNS: dict[str, str] = {
|
||||
"ATEM Switchers": r"^ATEM Switchers\s+([\d.]+)\s+Update$",
|
||||
"Desktop Video": r"^Desktop Video\s+([\d.]+)$",
|
||||
"Blackmagic Converters": r"^Blackmagic Converters\s+([\d.]+)\s+Update$",
|
||||
"HyperDeck": r"^HyperDeck\s+([\d.]+)\s+Update$",
|
||||
"MultiView": r"^Blackmagic Multi\s?[Vv]iew\s+([\d.]+)",
|
||||
"Camera": r"^Blackmagic Camera\s+([\d.]+)",
|
||||
"Videohub": r"^Videohub\s+([\d.]+)",
|
||||
"Web Presenter": r"^Web Presenter\s+([\d.]+)",
|
||||
"DaVinci Resolve": r"^DaVinci Resolve\s+([\d.]+)",
|
||||
}
|
||||
|
||||
BETA_FINAL_SENTINEL = 255
|
||||
|
||||
|
||||
def _load_catalogue(ctx: Context) -> list[dict]:
|
||||
def fetch() -> list[dict]:
|
||||
# The catalogue is ~1.6 MB; a 6 hour TTL is plenty and keeps us off
|
||||
# their servers during multi-run days.
|
||||
resp = ctx.http.get(DOWNLOADS_URL, ttl=6 * 3600)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"HTTP {resp.status} from BMD downloads feed")
|
||||
data = resp.json()
|
||||
entries = data.get("downloads")
|
||||
if not isinstance(entries, list):
|
||||
raise RuntimeError("unexpected BMD payload: no 'downloads' list")
|
||||
return entries
|
||||
|
||||
return ctx.memo("bmd:catalogue", fetch)
|
||||
|
||||
|
||||
def _is_beta(entry: dict) -> bool:
|
||||
for builds in (entry.get("urls") or {}).values():
|
||||
if builds and isinstance(builds, list):
|
||||
beta = builds[0].get("beta")
|
||||
if beta is not None and beta != BETA_FINAL_SENTINEL:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _released(entry: dict) -> datetime | None:
|
||||
ms = entry.get("numericDate")
|
||||
if not isinstance(ms, (int, float)):
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc)
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class BlackmagicSource:
|
||||
kind = "blackmagic"
|
||||
|
||||
def check(self, item: Item, ctx: Context) -> VersionCheck:
|
||||
family = item.source.get("family")
|
||||
if not family:
|
||||
return error(item, "blackmagic source requires 'family'")
|
||||
|
||||
pattern = item.source.get("pattern") or FAMILY_PATTERNS.get(family)
|
||||
if not pattern:
|
||||
return error(
|
||||
item,
|
||||
f"unknown BMD family {family!r}; set an explicit 'pattern' "
|
||||
f"or use one of: {', '.join(sorted(FAMILY_PATTERNS))}",
|
||||
)
|
||||
|
||||
try:
|
||||
catalogue = _load_catalogue(ctx)
|
||||
except RuntimeError as exc:
|
||||
return error(item, str(exc))
|
||||
|
||||
rx = re.compile(pattern)
|
||||
include_beta = bool(item.source.get("include_beta", False))
|
||||
|
||||
best: tuple | None = None
|
||||
for entry in catalogue:
|
||||
name = (entry.get("name") or "").strip()
|
||||
m = rx.match(name)
|
||||
if not m:
|
||||
continue
|
||||
if not include_beta and _is_beta(entry):
|
||||
continue
|
||||
ver = parse(m.group(1))
|
||||
if not ver.valid:
|
||||
continue
|
||||
if best is None or ver > best[0]:
|
||||
best = (ver, entry)
|
||||
|
||||
if best is None:
|
||||
return error(item, f"no releases matched pattern for family {family!r}")
|
||||
|
||||
ver, entry = best
|
||||
notes = (entry.get("desc") or "").strip() or None
|
||||
title = entry.get("releaseNotesTitle")
|
||||
|
||||
return VersionCheck(
|
||||
item_id=item.id,
|
||||
latest=ver.raw,
|
||||
released=_released(entry),
|
||||
status="ok",
|
||||
release_notes=notes,
|
||||
release_url=item.check_url or "https://www.blackmagicdesign.com/support",
|
||||
detail=title,
|
||||
)
|
||||
Reference in New Issue
Block a user