diff --git a/CHANGELOG.md b/CHANGELOG.md index 370669f..6263ca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Install Posteryard on Unraid from the Apps tab, listed in Community Applications. - Default the Unraid webhook port to 8098, as many Unraid apps already use 8000. - Restart the Unraid container after a crash. +- Show the latest release notes in the Unraid template, and take its settings help from the README. - Show the settings panel's lines for the install method picked on the landing page. - Name Posteryard's version and project page in every web request, as Wikimedia asks of programs that download its logos. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08a3022..528b769 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,7 +92,7 @@ UPDATE_GOLDEN=1 uv run pytest tests/test_golden.py A change to the database schema adds a new entry at the end of `MIGRATIONS` in `src/posteryard/store.py`. Never edit an entry that was released. -A new setting also goes into the Unraid template, `templates/posteryard.xml`, with its default as the value, and into `site/settings.json`. `tests/test_unraid.py` fails when a template default does not load or a template setting is missing from `README.md`. +A new setting also goes into the Unraid template, `templates/posteryard.xml`, and into `site/settings.json`. The template takes each setting's description and default from its row in the `README.md` Settings table, and its `Changes` from the newest dated `CHANGELOG.md` section. Add the field with its name, display and mask, then run `uv run python tools/unraid_template.py`. `tests/test_unraid.py` fails when the template is out of date, a template default does not load, or a template setting has no README row. ## Website @@ -118,4 +118,4 @@ The Plex preview shows either 12 random movies or 12 random shows from `site/pos ## Releases -Semantic versioning. Move the `Unreleased` entries in [CHANGELOG.md](CHANGELOG.md) under the new version with its date, set `version` in `pyproject.toml`, merge, and push a `vX.Y.Z` tag. The tag runs the CI checks, then pushes `ghcr.io/sandobserver/posteryard:X.Y.Z`, `:X.Y` and `:latest`, and publishes a GitHub release with the version's CHANGELOG section. The release stops if the tag, `pyproject.toml` and a dated CHANGELOG section do not agree. The image carries an SBOM and build provenance. A tag with a suffix, such as `v1.0.0-rc.1`, publishes a prerelease: only the `1.0.0-rc.1` image tag, and a GitHub release marked as prerelease. +Semantic versioning. Move the `Unreleased` entries in [CHANGELOG.md](CHANGELOG.md) under the new version with its date, set `version` in `pyproject.toml`, run `uv run python tools/unraid_template.py` to copy the notes into the Unraid template, merge, and push a `vX.Y.Z` tag. The tag runs the CI checks, then pushes `ghcr.io/sandobserver/posteryard:X.Y.Z`, `:X.Y` and `:latest`, and publishes a GitHub release with the version's CHANGELOG section. The release stops if the tag, `pyproject.toml` and a dated CHANGELOG section do not agree. The image carries an SBOM and build provenance. A tag with a suffix, such as `v1.0.0-rc.1`, publishes a prerelease: only the `1.0.0-rc.1` image tag, and a GitHub release marked as prerelease. diff --git a/README.md b/README.md index 1d07513..6e18eae 100644 --- a/README.md +++ b/README.md @@ -125,15 +125,21 @@ curl -fsSL -o /boot/config/plugins/dockerMan/templates-user/my-Posteryard.xml \ ## Settings -Every setting has a default. Add a line under `environment:` only to change one. The [landing page](https://posteryard.sandobserver.com/#settings) builds these lines for you. +Every setting except the first four has a default. Add a line under `environment:` only to change one. The [landing page](https://posteryard.sandobserver.com/#settings) builds these lines for you. | Setting | What it does | Default | | --- | --- | --- | +| `TMDB_API_KEY` | A free key from [TMDB](https://www.themoviedb.org/settings/api), under **Settings**, **API**. The API Read Access Token works too. | | +| `PLEX_URL` | Your Plex server's address on the network, such as `http://192.168.1.10:32400`. Not `localhost`. Leave it empty for Jellyfin or Emby. | | +| `PLEX_TOKEN` | Your [Plex token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/). Leave it empty for Jellyfin or Emby. | | +| `WEBHOOK_SECRET` | Any long random text, such as the output of `openssl rand -hex 16`. It is part of the webhook address. | | | `DRY_RUN` | `true` saves previews and changes nothing on your server. `false` uploads the images and locks them. | `true` | | `LIBRARIES` | Libraries to manage, separated by commas, named as your server shows them. Movie and TV libraries only. `PLEX_LIBRARIES` works too. | `Movies,TV Shows` | | `ONLY_RATING_KEYS` | Limit Posteryard to a few titles, for a first test. [Rating keys](docs/commands.md#rating-keys) separated by commas. A show includes its seasons and episodes. | | -| `JELLYFIN_URL`, `JELLYFIN_API_KEY` | Use Jellyfin instead of Plex. See [Jellyfin and Emby](docs/jellyfin-and-emby.md). | | -| `EMBY_URL`, `EMBY_API_KEY` | Use Emby instead of Plex. See [Jellyfin and Emby](docs/jellyfin-and-emby.md). | | +| `JELLYFIN_URL` | Your Jellyfin server's address. Use it instead of `PLEX_URL` and `PLEX_TOKEN`. See [Jellyfin and Emby](docs/jellyfin-and-emby.md). | | +| `JELLYFIN_API_KEY` | A Jellyfin API key, from **API Keys** in the Jellyfin dashboard. | | +| `EMBY_URL` | Your Emby server's address. Use it instead of `PLEX_URL` and `PLEX_TOKEN`. See [Jellyfin and Emby](docs/jellyfin-and-emby.md). | | +| `EMBY_API_KEY` | An Emby API key, from **API Keys** in the Emby dashboard. | | | `QUALITY_MIN_VIDEO` | Lowest resolution that gets a badge: `off`, `720`, `1080` or `2160`. | `2160` | | `QUALITY_MIN_HDR` | Lowest HDR format that gets a badge: `off`, `hdr10`, `hdr10plus` or `dolbyvision`. | `hdr10` | | `QUALITY_MIN_AUDIO` | Lowest audio that gets a badge: `off`, `5.1`, `7.1` or `atmos`. DTS:X counts as `atmos`. | `atmos` | diff --git a/templates/posteryard.xml b/templates/posteryard.xml index e307d67..edf7982 100644 --- a/templates/posteryard.xml +++ b/templates/posteryard.xml @@ -12,7 +12,21 @@ https://github.com/SandObserver/Posteryard/blob/main/LICENSE Clean, consistent posters for Plex, Jellyfin and Emby, picked, rendered and kept current automatically.[br][br]Posteryard gives every movie, show, season and episode the same calm look: clean art without printed text, the title logo in one fixed spot, a soft fade, and small badges for 4K, Dolby Vision, Dolby Atmos and the streaming service. It finds the art on TMDB, checks it, renders it, uploads it and keeps it up to date. With Live TV on, it does the same for the shows and movies in the Plex or Jellyfin guide.[br][br]It starts with Dry Run on: images go to the previews folder in Data and the server is not touched. Set Dry Run to false when you like them.[br][br]For new titles right away, add the webhook http://UNRAID-IP:8098/webhook/YOUR-WEBHOOK-SECRET in Plex under Settings, Webhooks. For Jellyfin and Emby, see the README. Without it, new titles get posters within 15 minutes. A free TMDB API key and a Plex, Jellyfin or Emby server. - https://github.com/SandObserver/Posteryard/blob/main/CHANGELOG.md + +### 0.9.0 - 2026-10-12 + +#### Added + +- Add posters for the Live TV guide on Plex and Jellyfin with `LIVE_TV`. + +#### Changed + +- Try a request again at once, without a warning, when a server has dropped an idle connection. + +#### Security + +- Update the Python base image to 3.13.16 for CVE-2026-19445. + MediaApp:Video Tools:Utilities https://raw.githubusercontent.com/SandObserver/Posteryard/main/templates/posteryard.xml https://raw.githubusercontent.com/SandObserver/Posteryard/main/site/public/icon-512.png @@ -22,37 +36,37 @@ https://buymeacoffee.com/sandobserver 8098 /mnt/user/appdata/posteryard - - - - - true - Movies,TV Shows - US - - problems - - - - - - - false - true - - 2160 - hdr10 - atmos - - true - plain - en - true - false + + + + + true + Movies,TV Shows + US + + problems + + + + + + + false + true + + 2160 + hdr10 + atmos + + true + plain + en + true + false false - false - 15 - 04:15 - + false + 15 + 04:15 + info diff --git a/tests/test_unraid.py b/tests/test_unraid.py index fb48915..b962586 100644 --- a/tests/test_unraid.py +++ b/tests/test_unraid.py @@ -1,14 +1,45 @@ +import importlib.util import re import xml.etree.ElementTree as ET from pathlib import Path +from types import ModuleType + +import pytest from posteryard import config ROOT = Path(__file__).parent.parent TEMPLATE = ET.parse(ROOT / "templates" / "posteryard.xml").getroot() +CHANGELOG = """## [Unreleased] + +### Added + +- Not shipped yet. + +## [1.2.0] - 2026-01-02 + +### Fixed + +- Keep "quotes" & . + +## [1.1.0] - 2026-01-01 + +[Unreleased]: https://example.com/compare/v1.2.0...HEAD +""" REQUIRED = ("Name", "Repository", "Overview", "Category", "Support", "Project", "TemplateURL", "Icon") +def _tool() -> ModuleType: + spec = importlib.util.spec_from_file_location("unraid_template", ROOT / "tools" / "unraid_template.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +TOOL = _tool() + + def _configs(kind: str) -> dict[str, str]: return {c.attrib["Target"]: c.text or "" for c in TEMPLATE.iter("Config") if c.attrib["Type"] == kind} @@ -42,3 +73,46 @@ def test_the_template_images_are_in_the_repository() -> None: images = [TEMPLATE.findtext("Icon") or "", *(s.text or "" for s in TEMPLATE.iter("Screenshot"))] assert [url for url in images if not url.startswith(raw) or not (ROOT / url.removeprefix(raw)).is_file()] == [] assert (TEMPLATE.findtext("Icon") or "").endswith(".png") + + +def test_the_template_matches_the_readme_settings_and_the_newest_release() -> None: + template = (ROOT / "templates" / "posteryard.xml").read_text() + readme = (ROOT / "README.md").read_text() + changelog = (ROOT / "CHANGELOG.md").read_text() + assert TOOL.render(template, readme, changelog) == template, "Run: uv run python tools/unraid_template.py" + assert TOOL.main(["--check"]) == 0 + + +def test_a_readme_line_becomes_plain_text_without_links_to_other_pages() -> None: + line = "Use **Apple** art from [TMDB](https://example.com) for `true`. See [Collections](docs/x.md#c). Next." + assert TOOL.plain(line) == "Use Apple art from TMDB for true. Next." + + +def test_the_changes_are_the_newest_dated_release_only() -> None: + changes = TOOL.newest_release(CHANGELOG) + assert changes == '### 1.2.0 - 2026-01-02\n\n#### Fixed\n\n- Keep "quotes" & .' + + +def test_a_changelog_without_a_release_is_refused() -> None: + with pytest.raises(ValueError, match="no dated release"): + TOOL.newest_release("## [Unreleased]\n") + + +def test_rendered_text_survives_xml_escaping() -> None: + readme = '\n## Settings\n\n| `LOG_LEVEL` | Say "hi" & . | `info` |\n\n## Next\n' + template = ( + "old" + 'x' + "" + ) + root = ET.fromstring(TOOL.render(template, readme, CHANGELOG)) + field = root.find("Config") + assert field is not None + assert (field.get("Description"), field.get("Default"), field.text) == ('Say "hi" & .', "info", "info") + assert (root.findtext("Changes") or "").strip().endswith('Keep "quotes" & .') + + +def test_a_variable_missing_from_the_readme_is_refused() -> None: + template = '' + with pytest.raises(ValueError, match="NOT_DOCUMENTED"): + TOOL.render(template, "\n## Settings\n\n## Next\n", CHANGELOG) diff --git a/tools/unraid_template.py b/tools/unraid_template.py new file mode 100644 index 0000000..f2b4c38 --- /dev/null +++ b/tools/unraid_template.py @@ -0,0 +1,85 @@ +"""Copy the README settings and the newest release notes into templates/posteryard.xml. + +Each Unraid variable gets its description and default from its row in the README Settings table. +The Changes element gets the newest dated CHANGELOG section, never Unreleased. + +Run after a README settings change and in every release PR: uv run python tools/unraid_template.py +--check exits 1 when the template is out of date. +""" + +import re +import sys +from pathlib import Path +from xml.sax.saxutils import escape + +ROOT = Path(__file__).resolve().parent.parent +TEMPLATE = ROOT / "templates" / "posteryard.xml" +DATED = re.compile(r"^## \[([^\]]+)\] - (\d{4}-\d{2}-\d{2})$", re.M) +VARIABLE = re.compile( + r']*Type="Variable"[^>]*>[^<]*' +) + + +def attribute(text: str) -> str: + return '"' + escape(text, {'"': """}) + '"' + + +def plain(markdown: str) -> str: + sentences = re.split(r"(?<=\.) ", markdown) + text = " ".join(s for s in sentences if not s.startswith("See ")) + text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) + return text.replace("**", "").replace("`", "") + + +def readme_settings(readme: str) -> dict[str, tuple[str, str]]: + table = readme.split("\n## Settings\n", 1)[1].split("\n## ", 1)[0] + settings = {} + for line in table.splitlines(): + cells = [cell.strip() for cell in line.strip().strip("|").split("|")] + name = re.fullmatch(r"`([A-Z][A-Z0-9_]+)`", cells[0]) + if name: + settings[name.group(1)] = (plain(cells[1]), cells[2].strip("`")) + return settings + + +def newest_release(changelog: str) -> str: + heading = DATED.search(changelog) + if heading is None: + raise ValueError("CHANGELOG.md has no dated release section") + body = changelog[heading.end() :].split("\n## ", 1)[0] + lines = ["#" + line if line.startswith("### ") else line for line in body.splitlines()] + lines = [line for line in lines if not re.match(r"^\[[^\]]+\]: https?://", line)] + return f"### {heading.group(1)} - {heading.group(2)}\n\n" + "\n".join(lines).strip() + + +def render(template: str, readme: str, changelog: str) -> str: + settings = readme_settings(readme) + + def config(match: re.Match[str]) -> str: + name = match.group(1) + if name not in settings: + raise ValueError(f"README.md Settings has no row for {name}") + description, default = settings[name] + line = re.sub(r' Default="[^"]*"', lambda _: f" Default={attribute(default)}", match.group(0), count=1) + line = re.sub(r""" Description=("[^"]*"|'[^']*')""", lambda _: f" Description={attribute(description)}", line) + return re.sub(r">[^<]*$", lambda _: f">{escape(default)}", line) + + template = VARIABLE.sub(config, template) + changes = f"\n{escape(newest_release(changelog))}\n " + return re.sub(r".*?", lambda _: changes, template, count=1, flags=re.S) + + +def main(argv: list[str]) -> int: + current = TEMPLATE.read_text() + wanted = render(current, (ROOT / "README.md").read_text(), (ROOT / "CHANGELOG.md").read_text()) + if "--check" in argv: + if current != wanted: + print("templates/posteryard.xml is out of date. Run: uv run python tools/unraid_template.py") + return 1 + return 0 + TEMPLATE.write_text(wanted) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))