diff --git a/Plugin_repo.md b/Plugin_repo.md index 1e02efb7..608233a3 100644 --- a/Plugin_repo.md +++ b/Plugin_repo.md @@ -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)", diff --git a/apps/plugins/api_views.py b/apps/plugins/api_views.py index 80ab591a..08abe00e 100644 --- a/apps/plugins/api_views.py +++ b/apps/plugins/api_views.py @@ -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,