From fcf2f22a475ac1c77fa1d31e37f4959ced037eec Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Tue, 21 Apr 2026 11:40:29 +0200 Subject: [PATCH] feat(jellyfin): add media-server app using new path-type setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First app to use the core-26.10-alpha `path` setting type. User picks their existing media folder (MEDIA_PATH=/mnt/media) in the install form; core installer validates the path server-side; compose mounts it read-only at /media. Docker-managed volumes hold /config and /cache; admin account is created from the first browser visit to :8096. No HW transcoding yet — that's a later schema extension. Also bumps the vendored manifest schema in scripts/vendor/ to match core 26.10-alpha — catches up both the missing `open_url` field (gap since 26.6-alpha) and the new `path` setting type. No changes needed to validate-catalog.py itself. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 19 ++++++++++++++++ apps/jellyfin/.env.example | 1 + apps/jellyfin/docker-compose.yaml | 38 +++++++++++++++++++++++++++++++ apps/jellyfin/icon.svg | 5 ++++ apps/jellyfin/manifest.json | 20 ++++++++++++++++ scripts/vendor/furtka_manifest.py | 13 ++++++++++- 6 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 apps/jellyfin/.env.example create mode 100644 apps/jellyfin/docker-compose.yaml create mode 100644 apps/jellyfin/icon.svg create mode 100644 apps/jellyfin/manifest.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 9131d48..4f1284d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ Versioning: CalVer (`YY.N`) — same scheme as the core Furtka repo. ## [Unreleased] +### Added + +- **Jellyfin** (v1.0.0, image `jellyfin/jellyfin:latest`). Media + server for movies, shows, music on the LAN. First app to use the new + `path`-type setting (core ≥ 26.10-alpha): the user picks their + existing media folder in the install form (`MEDIA_PATH=/mnt/media`), + the installer validates the path server-side, and docker-compose + mounts it read-only at `/media`. Docker-managed volumes handle + `/config` and `/cache`. Admin account bootstraps from the first + browser visit to port 8096. Ports: 8096. No HW transcoding yet — + that's a later schema extension. + +### Changed + +- Vendored manifest schema (`scripts/vendor/furtka_manifest.py`) + caught up to core 26.10-alpha: adds the `open_url` field (missing + since 26.6-alpha cut) and the new `path` setting type. `validate- + catalog.py` now accepts path-type settings without changes. + ## [26.9-alpha] - 2026-04-21 ### Added diff --git a/apps/jellyfin/.env.example b/apps/jellyfin/.env.example new file mode 100644 index 0000000..fafd146 --- /dev/null +++ b/apps/jellyfin/.env.example @@ -0,0 +1 @@ +MEDIA_PATH= diff --git a/apps/jellyfin/docker-compose.yaml b/apps/jellyfin/docker-compose.yaml new file mode 100644 index 0000000..1b9f345 --- /dev/null +++ b/apps/jellyfin/docker-compose.yaml @@ -0,0 +1,38 @@ +# Furtka Jellyfin — media server. +# +# Two Docker-managed volumes (config, cache) for app state + one +# user-supplied host path (MEDIA_PATH) mounted read-only for the media +# library. Admin account bootstraps from the first browser visit to +# :8096 — that's why this app has no manifest.settings for admin creds. +# +# MEDIA_PATH is a `path`-type setting (furtka/manifest.py ≥ 26.10-alpha +# schema). The install form asks for it, the installer validates that +# the directory exists and isn't a system path, and docker-compose +# substitutes the value below at `docker compose up` time. +# +# TODO(image-pin): `:latest` is shaky for production — pin to a digest +# (`jellyfin/jellyfin@sha256:...`) or a stable tag once we've verified +# one against the upstream registry. MVP drift risk accepted. +# +# No HW transcoding yet — /dev/dri passthrough is a separate, later +# schema extension. 1080p software transcode + Direct Play over LAN +# are fine for the Medion-Haswell target. + +services: + jellyfin: + image: jellyfin/jellyfin:latest + restart: unless-stopped + ports: + - "8096:8096" + environment: + - TZ=Europe/Berlin + volumes: + - furtka_jellyfin_config:/config + - furtka_jellyfin_cache:/cache + - ${MEDIA_PATH}:/media:ro + +volumes: + furtka_jellyfin_config: + external: true + furtka_jellyfin_cache: + external: true diff --git a/apps/jellyfin/icon.svg b/apps/jellyfin/icon.svg new file mode 100644 index 0000000..640c4c9 --- /dev/null +++ b/apps/jellyfin/icon.svg @@ -0,0 +1,5 @@ + diff --git a/apps/jellyfin/manifest.json b/apps/jellyfin/manifest.json new file mode 100644 index 0000000..d47c27b --- /dev/null +++ b/apps/jellyfin/manifest.json @@ -0,0 +1,20 @@ +{ + "name": "jellyfin", + "display_name": "Jellyfin", + "version": "1.0.0", + "description": "Media server for movies, shows, and music on the LAN.", + "description_long": "Ein eigener Streaming-Server für deine Filme, Serien und Musik. Apps für Smart-TVs, Handy und Web-Browser verfügbar. Admin-Konto wird beim ersten Besuch im Browser angelegt. Medien werden nur gelesen, nicht verändert.", + "volumes": ["config", "cache"], + "ports": [8096], + "icon": "icon.svg", + "open_url": "http://{host}:8096/", + "settings": [ + { + "name": "MEDIA_PATH", + "label": "Medienordner", + "description": "Absoluter Pfad zu deinem Filme-/Serien-Ordner auf dieser Maschine, z.B. /mnt/media. Wird nur gelesen.", + "type": "path", + "required": true + } + ] +} diff --git a/scripts/vendor/furtka_manifest.py b/scripts/vendor/furtka_manifest.py index 83006a3..c70e9f0 100644 --- a/scripts/vendor/furtka_manifest.py +++ b/scripts/vendor/furtka_manifest.py @@ -13,7 +13,7 @@ REQUIRED_FIELDS = ( "icon", ) -VALID_SETTING_TYPES = frozenset({"text", "password", "number"}) +VALID_SETTING_TYPES = frozenset({"text", "password", "number", "path"}) SETTING_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$") @@ -42,6 +42,12 @@ class Manifest: icon: str description_long: str = "" settings: tuple[Setting, ...] = field(default_factory=tuple) + # Optional "Open" link for the landing page + installed-app row. + # `{host}` is substituted with the current browser hostname at render + # time so the URL follows whatever the user typed to reach Furtka — + # furtka.local, a raw IP, a future reverse-proxy hostname. Apps with + # no frontend (CLI-only, background workers) leave this empty. + open_url: str = "" def volume_name(self, short: str) -> str: # Namespace volume names so two apps can each declare e.g. "data" @@ -127,6 +133,10 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest: settings = _parse_settings(raw.get("settings"), path) + open_url_raw = raw.get("open_url", "") + if not isinstance(open_url_raw, str): + raise ManifestError(f"{path}: open_url must be a string if set") + return Manifest( name=name, display_name=str(raw["display_name"]), @@ -137,4 +147,5 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest: icon=str(raw["icon"]), description_long=str(raw.get("description_long", "")), settings=settings, + open_url=open_url_raw, )