Split manifest base URL

This commit is contained in:
Seth Van Niekerk 2026-06-14 09:34:33 -04:00
parent a8a52f3251
commit 092ac2c735
No known key found for this signature in database
GPG key ID: E86ACA677312A675
2 changed files with 91 additions and 27 deletions

View file

@ -120,12 +120,14 @@ If the name contains any of these, the repo will be rejected on add and skipped
### Top-Level Metadata
| Field | Required | Description |
| --------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `registry_name` | **Yes** | Display name for the repo. Must not contain words like "official" or "dispatcharr" that could be mistaken for an official repo (see [Name Restrictions](#name-restrictions)). |
| `registry_url` | No | URL to the repo's home page (e.g. GitHub). Used as a fallback for generating icon URLs. |
| `root_url` | No | Base URL for resolving relative URLs in plugin entries. Trailing slashes are stripped. |
| `plugins` | **Yes** | Array of plugin entry objects. |
| Field | Required | Description |
| -------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `registry_name` | **Yes** | Display name for the repo. Must not contain words like "official" or "dispatcharr" that could be mistaken for an official repo (see [Name Restrictions](#name-restrictions)). |
| `registry_url` | No | URL to the repo's home page (e.g. GitHub). Used as a fallback for generating icon URLs. |
| `root_url` | No | Generic base URL for resolving all relative URLs in plugin entries. Trailing slashes are stripped. Used as the fallback when neither `download_base_url` nor `metadata_base_url` is set. |
| `download_base_url` | No | Base URL for resolving relative download URLs (`latest_url` in plugin entries; `url` and `latest_url` inside per-plugin manifest `versions`/`latest`). Overrides `root_url` for download assets when set. |
| `metadata_base_url` | No | Base URL for resolving relative metadata URLs (`manifest_url` and `icon_url` in plugin entries). Overrides `root_url` for metadata assets when set. |
| `plugins` | **Yes** | Array of plugin entry objects. |
### Plugin Entry Fields
@ -155,13 +157,18 @@ Extra fields in a plugin entry are passed through to the frontend as-is, so you
### URL Resolution
If `root_url` is set and a URL field (`manifest_url`, `latest_url`, `icon_url`) does not start with `http://` or `https://`, it is treated as relative and resolved as:
Relative URL fields are resolved against a base URL. Dispatcharr uses two separate base URLs (one for metadata assets and one for download assets) so you can serve them from different origins (e.g., manifests and icons on GitHub Pages, release zips on a CDN).
```
{root_url}/{field_value}
```
**Resolution priority:**
This lets you keep plugin entries compact:
| Field(s) | Priority |
| --------- | -------- |
| `manifest_url`, `icon_url` | `metadata_base_url``root_url` |
| `latest_url` (plugin entries); `url`, `latest_url` (per-plugin manifest versions/latest) | `download_base_url``root_url` |
A field value is treated as relative if it does not start with `http://` or `https://`. Relative values are resolved as `{base_url}/{field_value}`. All base URL fields are optional; if none are set, URL fields must be absolute.
**Single base URL (simplest):** use `root_url` for everything:
```json
{
@ -177,12 +184,45 @@ This lets you keep plugin entries compact:
}
```
**Icon fallback:** If `icon_url` is missing and `registry_url` is set, Dispatcharr generates a fallback URL by converting the GitHub URL to a raw content URL:
**Split base URLs:** use `metadata_base_url` and `download_base_url` when assets are served from different origins:
```json
{
"metadata_base_url": "https://raw.githubusercontent.com/myorg/my-plugins/main",
"download_base_url": "https://cdn.example.com/releases",
"plugins": [
{
"slug": "my_plugin",
"manifest_url": "plugins/my_plugin/manifest.json",
"icon_url": "plugins/my_plugin/logo.png",
"latest_url": "my_plugin/my_plugin-1.0.0.zip"
}
]
}
```
{registry_url => raw.githubusercontent.com}/refs/heads/main/plugins/{slug}/logo.png
You can also combine `root_url` with one specific field. The specific field overrides for its consumers, and `root_url` covers the rest:
```json
{
"root_url": "https://raw.githubusercontent.com/myorg/my-plugins/main",
"download_base_url": "https://cdn.example.com/releases"
}
```
**Icon fallback:** If `icon_url` is missing, Dispatcharr tries two fallbacks in order:
1. **Manifest-directory fallback**: if a base URL is set (`root_url`, `metadata_base_url`, etc.) and `manifest_url` is present, the logo is assumed to live in the same directory as the per-plugin manifest:
```
{directory of resolved manifest_url}/logo.png
```
For example, if `manifest_url` resolves to `https://example.com/plugins/my_plugin/manifest.json`, the fallback icon URL is `https://example.com/plugins/my_plugin/logo.png`.
2. **GitHub fallback**: if `registry_url` is a GitHub URL, Dispatcharr converts it to a raw content URL:
```
{registry_url => raw.githubusercontent.com}/refs/heads/main/plugins/{slug}/logo.png
```
---
## Per-Plugin Manifest (Optional)
@ -563,7 +603,9 @@ You can host release zips as GitHub Release assets and reference them with absol
"manifest": {
"registry_name": "string (required)",
"registry_url": "string (optional)",
"root_url": "string (optional)",
"root_url": "string (optional, generic base URL fallback)",
"download_base_url": "string (optional, overrides root_url for zip download URLs)",
"metadata_base_url": "string (optional, overrides root_url for manifest_url and icon_url)",
"plugins": [
{
"slug": "string (required)",

View file

@ -335,16 +335,28 @@ def _save_fetched_manifest_to_repo(repo, data, verified):
return None
def _resolve_manifest_base_urls(manifest: dict) -> tuple[str, str]:
"""Return (download_base_url, metadata_base_url) from a manifest dict.
Both fields fall back to root_url when not set. All base URL fields are
optional; callers must guard against empty strings before building URLs.
"""
root_url = manifest.get("root_url", "").rstrip("/")
download_base_url = manifest.get("download_base_url", "").rstrip("/") or root_url
metadata_base_url = manifest.get("metadata_base_url", "").rstrip("/") or root_url
return download_base_url, metadata_base_url
def _invalidate_plugin_detail_cache(repo_id, manifest_data):
manifest = manifest_data.get("manifest", manifest_data)
root_url = manifest.get("root_url", "").rstrip("/")
_, metadata_base_url = _resolve_manifest_base_urls(manifest)
keys = []
for p in manifest.get("plugins", []):
url = p.get("manifest_url", "")
if not url:
continue
if root_url and not url.startswith(("http://", "https://")):
url = f"{root_url}/{url}"
if metadata_base_url and not url.startswith(("http://", "https://")):
url = f"{metadata_base_url}/{url}"
keys.append(f"plugin_detail:{repo_id}:{hashlib.md5(url.encode()).hexdigest()}")
if keys:
cache.delete_many(keys)
@ -1045,18 +1057,28 @@ class AvailablePluginsAPIView(PluginAuthMixin, APIView):
for repo in repos:
manifest_data = repo.cached_manifest or {}
manifest = manifest_data.get("manifest", manifest_data)
root_url = manifest.get("root_url", "").rstrip("/")
download_base_url, metadata_base_url = _resolve_manifest_base_urls(manifest)
registry_url = manifest.get("registry_url", "").rstrip("/")
repo_plugins = manifest.get("plugins", [])
for p in repo_plugins:
slug = p.get("slug", "")
plugin_data = {**p}
# Resolve relative URLs against root_url; absolute URLs pass through
if root_url:
for url_field in ("manifest_url", "latest_url", "icon_url"):
# Resolve relative URLs; metadata and download assets use separate bases
if metadata_base_url:
for url_field in ("manifest_url", "icon_url"):
val = plugin_data.get(url_field, "")
if val and not val.startswith(("http://", "https://")):
plugin_data[url_field] = f"{root_url}/{val}"
plugin_data[url_field] = f"{metadata_base_url}/{val}"
if download_base_url:
val = plugin_data.get("latest_url", "")
if val and not val.startswith(("http://", "https://")):
plugin_data["latest_url"] = f"{download_base_url}/{val}"
# Fallback icon_url: if metadata_base_url is explicitly set and manifest_url
# is known, assume logo.png lives in the same directory as the per-plugin
# manifest. Guard against root_url-only manifests to preserve the GitHub fallback.
if not plugin_data.get("icon_url") and manifest.get("metadata_base_url") and plugin_data.get("manifest_url"):
manifest_dir = plugin_data["manifest_url"].rsplit("/", 1)[0]
plugin_data["icon_url"] = f"{manifest_dir}/logo.png"
# Fallback icon_url from main branch when not provided
if not plugin_data.get("icon_url") and registry_url:
# registry_url is e.g. https://github.com/Dispatcharr/Plugins
@ -1161,18 +1183,18 @@ class PluginDetailManifestAPIView(PluginAuthMixin, APIView):
# Resolve relative URLs in versions
repo_manifest = repo.cached_manifest or {}
inner = repo_manifest.get("manifest", repo_manifest)
root_url = inner.get("root_url", "").rstrip("/")
download_base_url, _ = _resolve_manifest_base_urls(inner)
if root_url and isinstance(manifest_obj.get("versions"), list):
if download_base_url and isinstance(manifest_obj.get("versions"), list):
for v in manifest_obj["versions"]:
url_val = v.get("url", "")
if url_val and not url_val.startswith(("http://", "https://")):
v["url"] = f"{root_url}/{url_val}"
if root_url and isinstance(manifest_obj.get("latest"), dict):
v["url"] = f"{download_base_url}/{url_val}"
if download_base_url and isinstance(manifest_obj.get("latest"), dict):
for url_field in ("url", "latest_url"):
url_val = manifest_obj["latest"].get(url_field, "")
if url_val and not url_val.startswith(("http://", "https://")):
manifest_obj["latest"][url_field] = f"{root_url}/{url_val}"
manifest_obj["latest"][url_field] = f"{download_base_url}/{url_val}"
result = {
"manifest": manifest_obj,