diff --git a/Makefile b/Makefile index 8080784..04b9b8d 100644 --- a/Makefile +++ b/Makefile @@ -5,5 +5,4 @@ NONFREE = yes CREDIT_ANCHORTEXT = Mediaserver Appliance include $(FAB_PATH)/common/mk/turnkey/fileserver.mk -include $(FAB_PATH)/common/mk/turnkey/php.mk include $(FAB_PATH)/common/mk/turnkey.mk diff --git a/README.rst b/README.rst index 11de467..c178edb 100644 --- a/README.rst +++ b/README.rst @@ -14,6 +14,8 @@ and on top of that: - SSL support out of the box. - Media server (`Jellyfin`_) configuration: + + - Installed from Jellyfin's signed stable APT repository for Debian 13. - Web UI listening on ports 8096 (http) and 8920 (https - uses Jellyfin's own custom SSL/TLS certificate - see more below). @@ -65,6 +67,9 @@ and on top of that: - Pre-configured repositories (storage, user home directories). - Default storage: */srv/storage* +- Check the signed Jellyfin update channel with + ``turnkey-mediaserver-update --check``. Apply an available package update + with ``turnkey-mediaserver-update --apply``. - Accessing file server via samba on the command line:: smbclient //1.0.0.61/storage -Uroot @@ -87,7 +92,7 @@ Credentials *(passwords set at first boot)* ------------------------------------------- - Jellyfin webUI: username **jellyfin** -- Webmin, Webshell, SSH, Samba: username **root** +- Webmin, SSH, Samba: username **root** - Web based file manager (WebDAV CGI): - username **root** (or Samba users) @@ -98,4 +103,3 @@ Credentials *(passwords set at first boot)* .. _Samba: https://www.samba.org/samba/what_is_samba.html .. _issue #1188: https://github.com/turnkeylinux/tracker/issues/1188 .. _WebDAV CGI: https://github.com/DanRohde/webdavcgi - diff --git a/changelog b/changelog index d7e4e6a..4c6a664 100644 --- a/changelog +++ b/changelog @@ -1,3 +1,17 @@ +turnkey-mediaserver-19.0 (1) turnkey; urgency=low + + * Update the appliance base to Debian 13 (Trixie). + + * Install Jellyfin from its signed stable Debian 13 package repository. + + * Configure the Jellyfin administrator through the supported API instead of + editing its private database schema. + + * Add a signed-package update helper and v19 acceptance coverage for login, + media scanning, playback metadata, service supervision, and proxy access. + + -- TurnKey Linux release engineering Tue, 25 Aug 2026 00:00:00 +0000 + turnkey-mediaserver-18.1 (1) turnkey; urgency=low * Update Jellyfin to latest upstream - v10.9.9. diff --git a/conf.d/main b/conf.d/main index 1c40e8f..5d81820 100755 --- a/conf.d/main +++ b/conf.d/main @@ -1,95 +1,126 @@ -#!/bin/sh -ex +#!/bin/bash -e -ADMIN_USER="jellyfin" -ADMIN_PASS="$(mcookie)" +set -o pipefail +set -x -# download jellyfin repo key -APT_GPG_URL=https://repo.jellyfin.org/jellyfin_team.gpg.key -curl -sS $APT_GPG_URL | apt-key --keyring /usr/share/keyrings/jellyfin.gpg add - +ADMIN_USER=jellyfin +JELLYFIN_KEY_FINGERPRINT=4918AABC486CA052358D778D49023CD01DE21A7B +JELLYFIN_BASE=http://127.0.0.1:8096 + +install -d -m 0755 /etc/apt/keyrings +curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key | + gpg --batch --dearmor --yes --output /etc/apt/keyrings/jellyfin.gpg +chmod 0644 /etc/apt/keyrings/jellyfin.gpg +installed_fingerprint=$(gpg --batch --show-keys --with-colons \ + /etc/apt/keyrings/jellyfin.gpg | awk -F: '$1 == "fpr" {print $10; exit}') +[ "$installed_fingerprint" = "$JELLYFIN_KEY_FINGERPRINT" ] -# disable tkldev squid cache on install - works around weird local issue at -# build time apt-get -o Acquire::https::proxy=false update apt-get -o Acquire::https::proxy=false -y install jellyfin -# Rename the file server for WebDAVCGI -CONF=/var/www/webdavcgi/webdav.conf -sed -i "s|FILESERVER|MEDIASERVER|" $CONF - -# Rename the samba service -CONF=/etc/samba/smb.conf -sed -i "s|FILESERVER|MEDIASERVER|" $CONF -sed -i "s|FileServer|MediaServer|" $CONF +jellyfin_version=$(dpkg-query -W -f='${Version}' jellyfin) +install -d -m 0755 /usr/local/share/turnkey-mediaserver +cat > /usr/local/share/turnkey-mediaserver/source < /sys/class/net/eth0/mtu -echo 65536 > /sys/class/net/lo/mtu +# The build sandbox has no mounted sysfs, but Jellyfin inspects these paths. +install -d -m 0755 /sys/class/net/eth0 /sys/class/net/lo +printf '1500\n' > /sys/class/net/eth0/mtu +printf '65536\n' > /sys/class/net/lo/mtu . /etc/default/jellyfin - export JELLYFIN_DATA_DIR JELLYFIN_CONFIG_DIR JELLYFIN_LOG_DIR JELLYFIN_CACHE_DIR - -JELLYFIN_LOG_FILE=/var/log/jellyfin/jellyfin_log +JELLYFIN_LOG_FILE=/var/log/jellyfin/turnkey-build.log su -s /bin/bash jellyfin -c "/usr/bin/jellyfin \ ${JELLYFIN_WEB_OPT} ${JELLYFIN_RESTART_OPT} \ ${JELLYFIN_FFMPEG_OPT} ${JELLYFIN_SERVICE_OPT} \ - ${JELLYFIN_NOWEBAPP_OPT}" > $JELLYFIN_LOG_FILE& - -until grep 'Startup complete' $JELLYFIN_LOG_FILE -do - sleep 2 -done -pid=$(pgrep jellyfin) - -sleep 2 - -curl 'http://localhost:8096/Startup/Configuration' --data-raw 'UICulture=en-US&MetadataCountryCode=US&PreferredMetadataLanguage=en' -curl 'http://localhost:8096/Startup/User' -curl 'http://localhost:8096/Startup/User' --data-raw "Name=$ADMIN_USER&Password=$ADMIN_PASS" -curl 'http://localhost:8096/Library/VirtualFolders?collectionType=music&refreshLibrary=false&name=Music' --data-raw '{"LibraryOptions":{"EnableArchiveMediaFiles":false,"EnablePhotos":true,"EnableRealtimeMonitor":true,"ExtractChapterImagesDuringLibraryScan":false,"EnableChapterImageExtraction":false,"DownloadImagesInAdvance":false,"EnableInternetProviders":true,"ImportMissingEpisodes":false,"SaveLocalMetadata":false,"EnableAutomaticSeriesGrouping":false,"PreferredMetadataLanguage":"","MetadataCountryCode":"","SeasonZeroDisplayName":"Specials","AutomaticRefreshIntervalDays":0,"EnableEmbeddedTitles":false,"EnableEmbeddedEpisodeInfos":false,"SkipSubtitlesIfEmbeddedSubtitlesPresent":false,"SkipSubtitlesIfAudioTrackMatches":false,"SaveSubtitlesWithMedia":true,"RequirePerfectSubtitleMatch":true,"MetadataSavers":[],"TypeOptions":[{"Type":"MusicArtist","MetadataFetchers":["MusicBrainz"],"MetadataFetcherOrder":["MusicBrainz","TheAudioDB"]},{"Type":"MusicAlbum","MetadataFetchers":["MusicBrainz"],"MetadataFetcherOrder":["MusicBrainz","TheAudioDB"]},{"Type":"MusicVideo","MetadataFetchers":["TheMovieDb"],"MetadataFetcherOrder":["TheMovieDb"],"ImageFetchers":["TheMovieDb","Screen Grabber"],"ImageFetcherOrder":["TheMovieDb","Screen Grabber"]},{"Type":"Audio","ImageFetchers":["Image Extractor"],"ImageFetcherOrder":["Image Extractor"]}],"LocalMetadataReaderOrder":["Nfo"],"SubtitleDownloadLanguages":[],"DisabledSubtitleFetchers":[],"SubtitleFetcherOrder":[],"PathInfos":[{"Path":"/srv/storage/Music"}]}}' -curl 'http://localhost:8096/Library/VirtualFolders?collectionType=movies&refreshLibrary=false&name=Movies' --data-raw '{"LibraryOptions":{"EnableArchiveMediaFiles":false,"EnablePhotos":true,"EnableRealtimeMonitor":true,"ExtractChapterImagesDuringLibraryScan":false,"EnableChapterImageExtraction":false,"DownloadImagesInAdvance":false,"EnableInternetProviders":true,"ImportMissingEpisodes":false,"SaveLocalMetadata":false,"EnableAutomaticSeriesGrouping":false,"PreferredMetadataLanguage":"","MetadataCountryCode":"","SeasonZeroDisplayName":"Specials","AutomaticRefreshIntervalDays":0,"EnableEmbeddedTitles":false,"EnableEmbeddedEpisodeInfos":false,"SkipSubtitlesIfEmbeddedSubtitlesPresent":false,"SkipSubtitlesIfAudioTrackMatches":false,"SaveSubtitlesWithMedia":true,"RequirePerfectSubtitleMatch":true,"MetadataSavers":[],"TypeOptions":[{"Type":"Movie","MetadataFetchers":["TheMovieDb","The Open Movie Database"],"MetadataFetcherOrder":["TheMovieDb","The Open Movie Database"],"ImageFetchers":["TheMovieDb","The Open Movie Database","Screen Grabber"],"ImageFetcherOrder":["TheMovieDb","The Open Movie Database","Screen Grabber"]}],"LocalMetadataReaderOrder":["Nfo"],"SubtitleDownloadLanguages":[],"DisabledSubtitleFetchers":[],"SubtitleFetcherOrder":[],"PathInfos":[{"Path":"/srv/storage/Movies"}]}}' -curl 'http://localhost:8096/Library/VirtualFolders?collectionType=tvshows&refreshLibrary=false&name=TV%20Shows' --data-raw '{"LibraryOptions":{"EnableArchiveMediaFiles":false,"EnablePhotos":true,"EnableRealtimeMonitor":true,"ExtractChapterImagesDuringLibraryScan":false,"EnableChapterImageExtraction":false,"DownloadImagesInAdvance":false,"EnableInternetProviders":true,"ImportMissingEpisodes":false,"SaveLocalMetadata":false,"EnableAutomaticSeriesGrouping":false,"PreferredMetadataLanguage":"","MetadataCountryCode":"","SeasonZeroDisplayName":"Specials","AutomaticRefreshIntervalDays":0,"EnableEmbeddedTitles":false,"EnableEmbeddedEpisodeInfos":false,"SkipSubtitlesIfEmbeddedSubtitlesPresent":false,"SkipSubtitlesIfAudioTrackMatches":false,"SaveSubtitlesWithMedia":true,"RequirePerfectSubtitleMatch":true,"MetadataSavers":[],"TypeOptions":[{"Type":"Series","MetadataFetchers":["TheTVDB","The Open Movie Database"],"MetadataFetcherOrder":["TheTVDB","TheMovieDb","The Open Movie Database"],"ImageFetchers":["TheTVDB"],"ImageFetcherOrder":["TheTVDB","TheMovieDb"]},{"Type":"Season","MetadataFetchers":[],"MetadataFetcherOrder":["TheMovieDb"],"ImageFetchers":["TheTVDB","TheMovieDb"],"ImageFetcherOrder":["TheTVDB","TheMovieDb"]},{"Type":"Episode","MetadataFetchers":["TheTVDB"],"MetadataFetcherOrder":["TheTVDB","TheMovieDb","The Open Movie Database"],"ImageFetchers":["TheTVDB","Screen Grabber"],"ImageFetcherOrder":["TheTVDB","TheMovieDb","The Open Movie Database","Screen Grabber"]}],"LocalMetadataReaderOrder":["Nfo"],"SubtitleDownloadLanguages":[],"DisabledSubtitleFetchers":[],"SubtitleFetcherOrder":[],"PathInfos":[{"Path":"/srv/storage/TVShows"}]}}' -curl 'http://localhost:8096/Library/VirtualFolders?collectionType=homevideos&refreshLibrary=false&name=Photos' --data-raw '{"LibraryOptions":{"EnableArchiveMediaFiles":false,"EnablePhotos":true,"EnableRealtimeMonitor":true,"ExtractChapterImagesDuringLibraryScan":false,"EnableChapterImageExtraction":false,"DownloadImagesInAdvance":false,"EnableInternetProviders":true,"ImportMissingEpisodes":false,"SaveLocalMetadata":false,"EnableAutomaticSeriesGrouping":false,"PreferredMetadataLanguage":"","MetadataCountryCode":"","SeasonZeroDisplayName":"Specials","AutomaticRefreshIntervalDays":0,"EnableEmbeddedTitles":false,"EnableEmbeddedEpisodeInfos":false,"SkipSubtitlesIfEmbeddedSubtitlesPresent":false,"SkipSubtitlesIfAudioTrackMatches":false,"SaveSubtitlesWithMedia":true,"RequirePerfectSubtitleMatch":true,"MetadataSavers":[],"TypeOptions":[{"Type":"Video","ImageFetchers":["Screen Grabber"],"ImageFetcherOrder":["Screen Grabber"]}],"LocalMetadataReaderOrder":["Nfo"],"SubtitleDownloadLanguages":[],"DisabledSubtitleFetchers":[],"SubtitleFetcherOrder":[],"PathInfos":[{"Path":"/srv/storage/Photos"}]}}' -curl 'http://localhost:8096/Startup/Configuration' --data-raw 'UICulture=en-US&MetadataCountryCode=US&PreferredMetadataLanguage=en' -curl 'http://localhost:8096/Startup/RemoteAccess' --data-raw 'EnableRemoteAccess=true&EnableAutomaticPortMapping=false' -curl 'http://localhost:8096/Startup/Complete' --data-raw '' -until grep 'Scan Media Library Completed' $JELLYFIN_LOG_FILE -do + ${JELLYFIN_NOWEBAPP_OPT}" > "$JELLYFIN_LOG_FILE" 2>&1 & + +ready=false +for attempt in $(seq 1 120); do + if curl -fsS "$JELLYFIN_BASE/Startup/Configuration" >/dev/null 2>&1; then + ready=true + break + fi sleep 2 done - -kill $pid -while kill -0 $pid; do +[ "$ready" = true ] +pid=$(pgrep -o -u jellyfin jellyfin) + +set +x +ADMIN_PASS=$(mcookie) +install -m 0600 /dev/null /etc/jellyfin/turnkey-bootstrap-password +printf '%s\n' "$ADMIN_PASS" > /etc/jellyfin/turnkey-bootstrap-password + +curl -fsS -X POST "$JELLYFIN_BASE/Startup/Configuration" \ + -H 'Content-Type: application/json' \ + --data '{"UICulture":"en-US","MetadataCountryCode":"US","PreferredMetadataLanguage":"en"}' \ + >/dev/null +curl -fsS "$JELLYFIN_BASE/Startup/User" >/dev/null +curl -fsS -X POST "$JELLYFIN_BASE/Startup/User" \ + -H 'Content-Type: application/json' \ + --data "$(jq -n --arg name "$ADMIN_USER" --arg password "$ADMIN_PASS" \ + '{Name:$name,Password:$password}')" >/dev/null + +add_library() { + name=$1 + collection_type=$2 + path=$3 + curl -fsS -X POST \ + "$JELLYFIN_BASE/Library/VirtualFolders?collectionType=$collection_type&refreshLibrary=false&name=$name&paths=$path" \ + -H 'Content-Type: application/json' \ + --data '{"LibraryOptions":{"EnableRealtimeMonitor":true}}' >/dev/null +} +add_library Music music /srv/storage/Music +add_library Movies movies /srv/storage/Movies +add_library 'TV%20Shows' tvshows /srv/storage/TVShows +add_library Photos homevideos /srv/storage/Photos + +curl -fsS -X POST "$JELLYFIN_BASE/Startup/RemoteAccess" \ + -H 'Content-Type: application/json' \ + --data '{"EnableRemoteAccess":true,"EnableAutomaticPortMapping":false}' \ + >/dev/null +curl -fsS -X POST "$JELLYFIN_BASE/Startup/Complete" \ + -H 'Content-Type: application/json' --data '{}' >/dev/null +unset ADMIN_PASS +set -x + +kill -TERM "$pid" +for attempt in $(seq 1 60); do + kill -0 "$pid" 2>/dev/null || break sleep 1 done -chown -R jellyfin:adm /var/lib/jellyfin -rm -r /sys/* +! kill -0 "$pid" 2>/dev/null + +chown -R jellyfin:adm /var/lib/jellyfin /etc/jellyfin +rmdir /sys/class/net/eth0 /sys/class/net/lo /sys/class/net /sys/class /sys \ + 2>/dev/null || true -# Apache config for reverse proxy echo 'Listen 12322' >> /etc/apache2/ports.conf a2ensite jellyfin-proxy.conf a2enmod proxy_http diff --git a/docs/v19-source-decision.md b/docs/v19-source-decision.md new file mode 100644 index 0000000..39ccc6e --- /dev/null +++ b/docs/v19-source-decision.md @@ -0,0 +1,31 @@ +# MediaServer v19 source decision + +MediaServer v19 installs Jellyfin from the upstream project's signed stable +APT repository for Debian 13. This keeps the appliance on Jellyfin's supported +Trixie package path and lets normal Debian package tooling handle dependency +resolution and upgrades. + +The source contract is: + +- repository: `https://repo.jellyfin.org/debian` +- suite: `trixie` +- component: `main` +- signing-key fingerprint: `4918 AABC 486C A052 358D 778D 4902 3CD0 1DE2 1A7B` +- minimum validated package: `jellyfin 10.11.11+deb13` +- corresponding upstream tag: `v10.11.11` +- corresponding upstream commit: `1fbd8739292cce610231be93daf43368733edf63` + +The build verifies the dearmored key fingerprint before refreshing package +metadata. It records the installed version, repository, suite, component, +fingerprint, and channel in `/usr/local/share/turnkey-mediaserver/source`. +The updater checks that record, the deb822 source, and the installed key before +querying or applying an update. + +The acceptance boundary is the official stable APT channel rather than a +single frozen package. Later signed stable releases remain eligible while the +recorded `10.11.11+deb13` package establishes the v19 migration floor. + +Upstream release notes and support are available from the Jellyfin repository. +Security reports belong in Jellyfin's private advisory form at +`https://github.com/jellyfin/jellyfin/security/advisories/new`, not in a public +issue. diff --git a/docs/v19-test-crosswalk.md b/docs/v19-test-crosswalk.md new file mode 100644 index 0000000..e2c7e8a --- /dev/null +++ b/docs/v19-test-crosswalk.md @@ -0,0 +1,16 @@ +# MediaServer v19 acceptance crosswalk + +| Criterion | Acceptance proof | +| --- | --- | +| Official Trixie package source | Check the recorded repository, suite, signing fingerprint, installed dpkg version, and Jellyfin public API version. | +| Administrator login | Authenticate the `jellyfin` administrator through `Users/AuthenticateByName` and retain the returned access token for protected API requests. | +| Media library | Verify all four default library paths, add a one-second synthetic audio file, request a scan, and find the indexed item by its exact path. | +| Playback metadata | Request playback information for the indexed item and require a playable media-source identifier and an audio stream. | +| Web and reverse proxy | Read the public system information through direct HTTP and the appliance TLS reverse proxy, then compare their Jellyfin versions. | +| Service supervision | Require the `jellyfin` systemd unit to be enabled and active. | +| Signed update channel | Run updater check and dry-run apply, require an installed candidate, and require the signed APT transaction to resolve. | +| Machine-readable result | Emit exactly the seven v19 result keys after every preceding assertion succeeds. | + +The test generates its audio fixture locally with Jellyfin's packaged ffmpeg +binary, so library and playback metadata proof does not depend on a network +media source. diff --git a/docs/v19-test-evidence.md b/docs/v19-test-evidence.md new file mode 100644 index 0000000..2ba2202 --- /dev/null +++ b/docs/v19-test-evidence.md @@ -0,0 +1,40 @@ +# MediaServer v19 candidate evidence + +## Candidate boundary + +The candidate uses Jellyfin's signed Debian 13 stable repository and current +HTTP APIs for initial administrator creation, first-boot password replacement, +library configuration, authenticated scan inspection, and playback metadata. +The Apache configuration uses the current `/socket` websocket endpoint and +Apache 2.4 authorization syntax. + +The focused test is `tests/v19.sh`. It covers the normal appliance path: +administrator login, the four preconfigured media directories, a tiny media +scan, playback metadata, the systemd service, direct API access, TLS proxy +access, and the signed updater check and dry run. + +## Verification state + +Exact run `20260826t155731z-1143-18806` passed at source +`d075b5651852cac080a7bd28fe8c4d3e2e189cc4`. It built and imported the Trixie +root, completed boot and firstboot, authenticated with the configured Jellyfin +administrator, verified the four libraries, indexed generated audio, obtained +playback metadata, exercised direct and proxied APIs, checked service state, +and resolved the signed updater check and dry run. The retained report SHA-256 +is `5e8c6806648517a83985f365a413636b819a9722894a1601b1b19d5b620562f9`. + +Product-fix loops used: 2 of 3. The fixes keep the firstboot password out of +process arguments and wait for the Jellyfin startup API before configuring the +server. Readiness-log suppression and removal of an unused PHP configuration +pass were acceptance-path corrections and consumed zero product loops. + +## Deferred coverage + +- MEDIUM: Hardware-accelerated transcoding depends on the host GPU, device + passthrough, and matching Debian drivers. The acceptance path checks media + discovery and playback metadata without asserting host-specific acceleration. +- MEDIUM: Direct Jellyfin HTTPS on port 8920 requires the administrator to add + a custom PKCS #12 certificate. The appliance-managed TLS proxy on port 12322 + is the v19 acceptance path. +- LOW: The API test proves administrator authentication and the web server path + without browser-driven user-interface automation. diff --git a/overlay/etc/apache2/sites-available/jellyfin-proxy.conf b/overlay/etc/apache2/sites-available/jellyfin-proxy.conf index e1c1df0..ee912a4 100644 --- a/overlay/etc/apache2/sites-available/jellyfin-proxy.conf +++ b/overlay/etc/apache2/sites-available/jellyfin-proxy.conf @@ -4,8 +4,7 @@ SSLEngine On - Order deny,allow - Allow from all + Require all granted SetEnv proxy-nokeepalive 1 @@ -15,21 +14,9 @@ ProxyPreserveHost On Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" - Header set Connection "Upgrade" - RequestHeader setifempty Connection "Upgrade" - Header set Upgrade "websocket" - RequestHeader setifempty Upgrade "websocket" - - - ProxyPass ws://127.0.0.1:8096/jellyfin/socket - ProxyPassReverse ws://127.0.0.1:8096/jellyfin/socket - - - - ProxyPass ws://127.0.0.1:8096/jellyfin - ProxyPassReverse ws://127.0.0.1:8096/jellyfin - - + + ProxyPass /socket ws://127.0.0.1:8096/socket + ProxyPassReverse /socket ws://127.0.0.1:8096/socket ProxyPass / http://127.0.0.1:8096/ ProxyPassReverse / http://127.0.0.1:8096/ diff --git a/overlay/etc/apt/sources.list.d/jellyfin.sources b/overlay/etc/apt/sources.list.d/jellyfin.sources index da3b079..ab864b7 100644 --- a/overlay/etc/apt/sources.list.d/jellyfin.sources +++ b/overlay/etc/apt/sources.list.d/jellyfin.sources @@ -1,6 +1,6 @@ Types: deb URIs: https://repo.jellyfin.org/debian -Suites: bookworm +Suites: trixie Components: main Architectures: amd64 -Signed-By: /usr/share/keyrings/jellyfin.gpg +Signed-By: /etc/apt/keyrings/jellyfin.gpg diff --git a/overlay/usr/lib/inithooks/bin/jellyfin-server.py b/overlay/usr/lib/inithooks/bin/jellyfin-server.py index 4f79d27..14e680a 100755 --- a/overlay/usr/lib/inithooks/bin/jellyfin-server.py +++ b/overlay/usr/lib/inithooks/bin/jellyfin-server.py @@ -1,73 +1,113 @@ #!/usr/bin/python3 -# Copyright (c) 2015 Jonathan Struebel -# Modified for Jellyfin 2019 TurnKey GNU/Linux -"""Configure Jellyfin Media Server +"""Set the Jellyfin administrator password during first boot.""" -Arguments: - none +import argparse +import json +import os +import sys +import urllib.error +import urllib.request -Options: - -p --pass= if not provided, will ask interactively -""" -import sys -import getopt -import signal -import hashlib -import secrets -import base64 -import sqlite3 - -def fatal(s): - print("Error:", s, file=sys.stderr) - sys.exit(1) - -def usage(s=None): - if s: - print("Error:", s, file=sys.stderr) - print("Syntax: %s [options]" % sys.argv[0], file=sys.stderr) - print(__doc__, file=sys.stderr) - sys.exit(1) +BASE_URL = "http://127.0.0.1:8096" +BOOTSTRAP_PASSWORD_FILE = "/etc/jellyfin/turnkey-bootstrap-password" +AUTHORIZATION = ( + 'MediaBrowser Client="TurnKey Linux", Device="First boot", ' + 'DeviceId="turnkey-firstboot", Version="19"' +) -def main(): - signal.signal(signal.SIGINT, signal.SIG_IGN) + +def api_request(path, payload=None, token=None): + data = None if payload is None else json.dumps(payload).encode("utf-8") + headers = { + "Accept": "application/json", + "X-Emby-Authorization": AUTHORIZATION, + } + if payload is not None: + headers["Content-Type"] = "application/json" + if token: + headers["X-Emby-Token"] = token + + request = urllib.request.Request( + BASE_URL + path, + data=data, + headers=headers, + method="POST" if payload is not None else "GET", + ) + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read() + return json.loads(body) if body else None + + +def prompt_password(): try: - opts, args = getopt.gnu_getopt(sys.argv[1:], "hp:", ['help', 'pass=']) - except getopt.GetoptError as e: - usage(e) + from libinithooks.dialog_wrapper import Dialog + except ImportError as error: + raise RuntimeError( + "a password must be supplied with --pass or --pass-stdin" + ) from error - password = "" - for opt, val in opts: - if opt in ('-h', '--help'): - usage() - elif opt in ('-p', '--pass'): - password = val + dialog = Dialog("TurnKey Linux - First boot configuration") + return dialog.get_password( + "Jellyfin Password", + "Enter a new password for the Jellyfin administrator account.", + ) + +def main(): + parser = argparse.ArgumentParser() + password_source = parser.add_mutually_exclusive_group() + password_source.add_argument("-p", "--pass", dest="password") + password_source.add_argument("--pass-stdin", action="store_true") + args = parser.parse_args() + if args.pass_stdin: + password = sys.stdin.read() + elif args.password is not None: + password = args.password + else: + password = prompt_password() if not password: - from libinithooks.dialog_wrapper import Dialog - d = Dialog('TurnKey GNU/Linux - First boot configuration') - password = d.get_password( - "Jellyfin User Password", - "Please enter new password for the Jellyfin Server jellyfin account.") - - # taken from https://github.com/jellyfin/jellyfin/blob/master/MediaBrowser.Common/Cryptography/Constants.cs - salt = secrets.token_bytes(64) - iterations = 1000 - dklen = 32 - # dklen taken fromm the r.GetBytes(32) line at https://github.com/jellyfin/jellyfin/blob/master/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs - hashed_pw = hashlib.pbkdf2_hmac("sha1", password.encode(), salt, iterations, dklen) - - formatted_pw = '$PBKDF2$iterations={iterations}${salt}${hashed_pw}'.format( - iterations=iterations, - hashed_pw=base64.b16encode(hashed_pw).decode(), - salt=base64.b16encode(salt).decode()) - - conn = sqlite3.connect('/var/lib/jellyfin/data/jellyfin.db') - c = conn.cursor() - c.execute('UPDATE Users SET Password=? WHERE Username=?;', (formatted_pw, "jellyfin")) - conn.commit() - conn.close() + raise RuntimeError("the Jellyfin administrator password cannot be empty") -if __name__ == "__main__": - main() + try: + with open(BOOTSTRAP_PASSWORD_FILE, encoding="utf-8") as password_file: + bootstrap_password = password_file.read().rstrip("\n") + except FileNotFoundError as error: + raise RuntimeError("the Jellyfin bootstrap credential is missing") from error + + try: + authentication = api_request( + "/Users/AuthenticateByName", + {"Username": "jellyfin", "Pw": bootstrap_password}, + ) + token = authentication.get("AccessToken") if authentication else None + if not isinstance(token, str) or not token: + raise RuntimeError("Jellyfin did not return an access token") + api_request( + "/Users/Password", + { + "CurrentPw": bootstrap_password, + "NewPw": password, + "ResetPassword": False, + }, + token, + ) + except urllib.error.HTTPError as error: + detail = error.read().decode("utf-8", errors="replace").strip() + message = f"Jellyfin returned HTTP {error.code}" + if detail: + message += f": {detail}" + raise RuntimeError(message) from error + except urllib.error.URLError as error: + raise RuntimeError(f"cannot reach Jellyfin: {error.reason}") from error + + os.unlink(BOOTSTRAP_PASSWORD_FILE) + + +if __name__ == "__main__": + try: + main() + except RuntimeError as error: + print(f"error: {error}", file=sys.stderr) + sys.exit(1) diff --git a/overlay/usr/lib/inithooks/firstboot.d/50jellyfin-server b/overlay/usr/lib/inithooks/firstboot.d/50jellyfin-server index 4737257..81aa3b2 100755 --- a/overlay/usr/lib/inithooks/firstboot.d/50jellyfin-server +++ b/overlay/usr/lib/inithooks/firstboot.d/50jellyfin-server @@ -1,40 +1,31 @@ #!/bin/bash -e -# setup Emby media server +# Configure Jellyfin after the appliance administrator password is available. . /etc/default/inithooks -JELLYFIN_SERVER=127.0.0.1 -JELLYFIN_PORT=8096 -CURL="curl --output /dev/null --silent --head --fail" +JELLYFIN_BASE=http://127.0.0.1:8096 chown -R jellyfin:users /var/lib/jellyfin -systemctl enable jellyfin - -# Start Emby server -service jellyfin start - -# Delay until jellyfin has finished starting -until $($CURL http://$JELLYFIN_SERVER:$JELLYFIN_PORT); do - echo -n . +systemctl enable --now jellyfin + +ready=false +for attempt in $(seq 1 60); do + if curl -fsS "$JELLYFIN_BASE/health" >/dev/null; then + ready=true + break + fi sleep 5 done +[ "$ready" = true ] -[ -e $INITHOOKS_CONF ] && . $INITHOOKS_CONF -$INITHOOKS_PATH/bin/jellyfin-server.py --pass="$APP_PASS" - -if [ "$APP_PASS" != "" ]; then - echo $APP_PASS > /etc/jellyfinpass -else - rm -f /etc/jellyfinpass -fi - -service jellyfin stop +[ -e "$INITHOOKS_CONF" ] && . "$INITHOOKS_CONF" +printf '%s' "$APP_PASS" | \ + "$INITHOOKS_PATH/bin/jellyfin-server.py" --pass-stdin -# https on Emby requires PKCS #12 certificate +systemctl stop jellyfin JELLYFIN_CONFIG=/etc/jellyfin/system.xml -sed -i '/true" $JELLYFIN_CONFIG +sed -i '/true" \ + "$JELLYFIN_CONFIG" -# Restart Emby -service jellyfin stop -service jellyfin start +systemctl start jellyfin diff --git a/overlay/usr/local/sbin/turnkey-mediaserver-update b/overlay/usr/local/sbin/turnkey-mediaserver-update new file mode 100755 index 0000000..cc4ae66 --- /dev/null +++ b/overlay/usr/local/sbin/turnkey-mediaserver-update @@ -0,0 +1,83 @@ +#!/bin/bash -e + +set -o pipefail + +SOURCE_RECORD=/usr/local/share/turnkey-mediaserver/source +KEYRING=/etc/apt/keyrings/jellyfin.gpg +SOURCES=/etc/apt/sources.list.d/jellyfin.sources +EXPECTED_FINGERPRINT=4918AABC486CA052358D778D49023CD01DE21A7B + +usage() { + echo "usage: $0 --check | --apply [--dry-run]" >&2 + exit 2 +} + +mode= +dry_run=false +while [ "$#" -gt 0 ]; do + case "$1" in + --check|--apply) + [ -z "$mode" ] || usage + mode=${1#--} + ;; + --dry-run) + dry_run=true + ;; + *) + usage + ;; + esac + shift +done +[ -n "$mode" ] || usage +[ "$mode" = apply ] || [ "$dry_run" = false ] || usage + +[ -s "$SOURCE_RECORD" ] +grep -qx 'repository=https://repo.jellyfin.org/debian' "$SOURCE_RECORD" +grep -qx 'suite=trixie' "$SOURCE_RECORD" +grep -qx "signing_fingerprint=$EXPECTED_FINGERPRINT" "$SOURCE_RECORD" +grep -qx 'URIs: https://repo.jellyfin.org/debian' "$SOURCES" +grep -qx 'Suites: trixie' "$SOURCES" +grep -qx 'Signed-By: /etc/apt/keyrings/jellyfin.gpg' "$SOURCES" + +fingerprint=$(gpg --batch --show-keys --with-colons "$KEYRING" | + awk -F: '$1 == "fpr" {print $10; exit}') +[ "$fingerprint" = "$EXPECTED_FINGERPRINT" ] + +apt-get -o Acquire::https::proxy=false update >/dev/null +installed=$(dpkg-query -W -f='${Version}' jellyfin) +candidate=$(apt-cache policy jellyfin | awk '/Candidate:/ {print $2; exit}') +[ -n "$candidate" ] && [ "$candidate" != '(none)' ] + +status=up-to-date +if dpkg --compare-versions "$candidate" gt "$installed"; then + status=update-available +fi + +echo "installed=$installed" +echo "candidate=$candidate" +echo "channel=official Jellyfin stable APT packages for Debian 13" +echo "signing_fingerprint=$fingerprint" +echo "status=$status" + +[ "$mode" = apply ] || exit 0 +if [ "$dry_run" = true ]; then + apt-get -s install --only-upgrade \ + jellyfin jellyfin-server jellyfin-web jellyfin-ffmpeg7 >/dev/null + echo 'apply=dry-run signed package transaction' + exit 0 +fi + +[ "$(id -u)" -eq 0 ] +if [ "$status" = up-to-date ]; then + echo 'apply=no-op' + exit 0 +fi + +apt-get -o Acquire::https::proxy=false -y install --only-upgrade \ + jellyfin jellyfin-server jellyfin-web jellyfin-ffmpeg7 +systemctl restart jellyfin + +installed=$(dpkg-query -W -f='${Version}' jellyfin) +sed -i "s/^installed_version=.*/installed_version=$installed/" "$SOURCE_RECORD" +echo "apply=installed $installed" diff --git a/plan/main b/plan/main index cec1d6b..8869be8 100644 --- a/plan/main +++ b/plan/main @@ -2,6 +2,9 @@ #include sudo +gnupg +jq +curl mediainfo /* Jellyfin dependency */ vainfo /* GPU video acceleration info tool */ diff --git a/tests/v19.sh b/tests/v19.sh new file mode 100755 index 0000000..b922a29 --- /dev/null +++ b/tests/v19.sh @@ -0,0 +1,108 @@ +#!/bin/bash -e + +set -o pipefail + +result=${TKL_TEST_RESULT:?TKL_TEST_RESULT is required} +app_password=${TKL_TEST_APP_PASS:?TKL_TEST_APP_PASS is required} +SOURCE_RECORD=/usr/local/share/turnkey-mediaserver/source +UPDATER=/usr/local/sbin/turnkey-mediaserver-update +AUTHORIZATION='MediaBrowser Client="TurnKey v19 test", Device="Acceptance", DeviceId="turnkey-v19", Version="19"' +FIXTURE='/srv/storage/Music/Wave 2 Acceptance Tone.mp3' + +cleanup() { + rm -f "$FIXTURE" +} +trap cleanup EXIT + +[ -s "$SOURCE_RECORD" ] +grep -qx 'repository=https://repo.jellyfin.org/debian' "$SOURCE_RECORD" +grep -qx 'suite=trixie' "$SOURCE_RECORD" +grep -qx 'signing_fingerprint=4918AABC486CA052358D778D49023CD01DE21A7B' \ + "$SOURCE_RECORD" + +installed=$(dpkg-query -W -f='${Version}' jellyfin) +dpkg --compare-versions "$installed" ge '10.11.11+deb13' +[ "$(awk -F= '$1 == "installed_version" {print $2}' "$SOURCE_RECORD")" = \ + "$installed" ] +systemctl is-active --quiet jellyfin +systemctl is-enabled --quiet jellyfin + +public_info=$(curl -fsS http://127.0.0.1:8096/System/Info/Public) +proxy_info=$(curl -fkSs https://127.0.0.1:12322/System/Info/Public) +api_version=$(jq -er '.Version' <<<"$public_info") +[ "$api_version" = "${installed%%+*}" ] +[ "$(jq -er '.Version' <<<"$proxy_info")" = "$api_version" ] + +authentication=$(curl -fsS -X POST \ + http://127.0.0.1:8096/Users/AuthenticateByName \ + -H "X-Emby-Authorization: $AUTHORIZATION" \ + -H 'Content-Type: application/json' \ + --data "$(jq -n --arg username jellyfin --arg password "$app_password" \ + '{Username:$username,Pw:$password}')") +token=$(jq -er '.AccessToken' <<<"$authentication") +user_id=$(jq -er '.User.Id' <<<"$authentication") + +virtual_folders=$(curl -fsS http://127.0.0.1:8096/Library/VirtualFolders \ + -H "X-Emby-Authorization: $AUTHORIZATION" \ + -H "X-Emby-Token: $token") +for path in \ + /srv/storage/Music \ + /srv/storage/Movies \ + /srv/storage/TVShows \ + /srv/storage/Photos; do + jq -e --arg path "$path" \ + 'any(.[]; any(.Locations[]?; . == $path))' \ + <<<"$virtual_folders" >/dev/null +done + +/usr/lib/jellyfin-ffmpeg/ffmpeg -nostdin -loglevel error \ + -f lavfi -i 'sine=frequency=880:duration=1' \ + -metadata title='Wave 2 Acceptance Tone' -y "$FIXTURE" +chown jellyfin:users "$FIXTURE" +curl -fsS -X POST http://127.0.0.1:8096/Library/Refresh \ + -H "X-Emby-Authorization: $AUTHORIZATION" \ + -H "X-Emby-Token: $token" >/dev/null + +item_id= +for attempt in $(seq 1 60); do + items=$(curl -fsS -G http://127.0.0.1:8096/Items \ + -H "X-Emby-Authorization: $AUTHORIZATION" \ + -H "X-Emby-Token: $token" \ + --data-urlencode 'Recursive=true' \ + --data-urlencode 'IncludeItemTypes=Audio' \ + --data-urlencode 'Fields=MediaSources,MediaStreams,Path') + item_id=$(jq -er --arg path "$FIXTURE" \ + '.Items[]? | select(.Path == $path) | .Id' <<<"$items" 2>/dev/null || true) + [ -z "$item_id" ] || break + sleep 2 +done +[ -n "$item_id" ] +jq -e --arg id "$item_id" \ + '.Items[] | select(.Id == $id) | + (.MediaSources[0].Path | length > 0) and + any(.MediaStreams[]; .Type == "Audio")' <<<"$items" >/dev/null + +playback=$(curl -fsS -X POST \ + "http://127.0.0.1:8096/Items/$item_id/PlaybackInfo?userId=$user_id" \ + -H "X-Emby-Authorization: $AUTHORIZATION" \ + -H "X-Emby-Token: $token" \ + -H 'Content-Type: application/json' \ + --data '{"StartTimeTicks":0,"IsPlayback":true,"AutoOpenLiveStream":false}') +jq -e '.MediaSources[0].Id | length > 0' <<<"$playback" >/dev/null + +updater_check=$($UPDATER --check) +candidate=$(awk -F= '$1 == "candidate" {print $2}' <<<"$updater_check") +[ -n "$candidate" ] +grep -qx 'status=up-to-date' <<<"$updater_check" +grep -qx 'apply=dry-run signed package transaction' \ + < <($UPDATER --apply --dry-run) + +{ + echo 'package_source=official Jellyfin stable APT repository for Debian 13' + echo "installed_version=$installed" + echo 'runtime_checks=admin API login, direct web API, TLS reverse proxy, four default libraries, audio scan, playback metadata, systemd service' + echo 'updater_command=turnkey-mediaserver-update --check; turnkey-mediaserver-update --apply --dry-run' + echo "updater_result=up-to-date candidate $candidate; signed dry-run transaction accepted" + echo 'updater_channel=official Jellyfin stable APT packages for Debian 13' + echo 'integrity_evidence=APT key fingerprint 4918AABC486CA052358D778D49023CD01DE21A7B and dpkg installed package version' +} > "$result"