Homelab Academy / Modules
Guided practice + break/fix + lab gate.
Module: Requests & Media Servers
Activity type: Lab (Practice)
Lab risk: medium
Objective: Compare Plex and Jellyfin across licensing, account dependencies, remote access, plug-ins, client coverage, administration, and migration implications.
Every command that writes data is constrained to /opt/lab-classroom/class59/. Do not substitute production Plex, Jellyfin, or media paths.
Run each command block in the same root Bash session. Stop immediately if a command returns an unexpected nonzero status.
1
Preflight the isolated workspace
Confirm root access, required tools, and the absence of an existing class directory. The test against ROOT prevents accidental reuse or overwrite.
set -euo pipefail
ROOT=/opt/lab-classroom/class59
test "$(id -u)" -eq 0
for tool in install find sort xargs sha256sum tar awk python3 runuser stat cmp grep; do command -v "$tool" >/dev/null; done
test ! -e "$ROOT"
printf 'Preflight passed for %s\n' "$ROOT"
2
Create separated media, state, backup, report, and cutover areas
install -d creates directories. The -m option applies an explicit octal mode: 0755 permits traversal of the lab and media path, while 0750 restricts mutable state and reports to the owner and group.
umask 027
install -d -m 0755 "$ROOT" "$ROOT/media" "$ROOT/media/library"
install -d -m 0750 "$ROOT/state" "$ROOT/state/plex" "$ROOT/state/plex/config" "$ROOT/state/plex/metadata" "$ROOT/backups" "$ROOT/manifests" "$ROOT/reports" "$ROOT/cutover"
3
Create source fixtures and a read-only media fixture
The fixture represents only migration mechanics. The media file contains text rather than copyrighted media. The final mode 0444 denies file writes, and directory mode 0555 denies creation, deletion, or renaming by an unprivileged service identity.
cat > "$ROOT/state/plex/config/Preferences.xml" <<'EOF'
EOF
printf 'user\titem\tposition_seconds\tplayed\nlocal-alex\tdemo-item\t120\t0\n' > "$ROOT/state/plex/metadata/watch-state.tsv"
printf 'CLASS59 MEDIA FIXTURE\n' > "$ROOT/media/library/demo-media.txt"
chmod 0444 "$ROOT/media/library/demo-media.txt"
chmod 0555 "$ROOT/media/library"
printf 'plex\n' > "$ROOT/cutover/active-service"
cp "$ROOT/cutover/active-service" "$ROOT/cutover/active-service.before"
chmod 0444 "$ROOT/cutover/active-service.before"
4
Prove least-privilege media behavior
runuser executes the read and write attempts as the existing unprivileged nobody identity. A successful read and denied append prove behavior rather than merely recording a policy string. The append test is expected to fail.
runuser -u nobody -- cat "$ROOT/media/library/demo-media.txt" > /dev/null
if runuser -u nobody -- sh -c 'printf "unauthorized-change\n" >> "$1"' sh "$ROOT/media/library/demo-media.txt" 2> "$ROOT/reports/media-write-denied.txt"; then
printf 'ERROR: unprivileged media write unexpectedly succeeded\n' >&2
exit 1
else
printf 'PASS: nobody can read media but cannot append to it\n' | tee "$ROOT/reports/media-permission-result.txt"
fi
test "$(stat -c '%a' "$ROOT/media/library/demo-media.txt")" = 444
test "$(stat -c '%a' "$ROOT/media/library")" = 555
5
Create the source checksum manifest and backup archive
find -type f selects regular files. -print0, sort -z, and xargs -0 preserve filenames safely by using NUL delimiters. sha256sum records each file hash. tar -C archives the plex directory using a controlled relative path; -c creates, -z compresses with gzip, and -f selects the output file.
(cd "$ROOT/state" && find plex -type f -print0 | sort -z | xargs -0 sha256sum) > "$ROOT/manifests/plex.before.sha256"
(cd "$ROOT/state" && sha256sum -c "$ROOT/manifests/plex.before.sha256") | tee "$ROOT/reports/plex-source-check.txt"
tar -C "$ROOT/state" -czf "$ROOT/backups/plex-pre-migration.tgz" plex
tar -tzf "$ROOT/backups/plex-pre-migration.tgz" | tee "$ROOT/reports/backup-members.txt"
grep -Fx 'plex/config/Preferences.xml' "$ROOT/reports/backup-members.txt"
grep -Fx 'plex/metadata/watch-state.tsv' "$ROOT/reports/backup-members.txt"
6
Extract and verify the recovery archive before cutover
The archive is extracted into an empty isolated directory, not over the source. tar -x extracts, -z handles gzip, -f identifies the archive, and -C chooses the destination. sha256sum -c then proves the extracted files match the pre-migration manifest.
install -d -m 0750 "$ROOT/restore-check"
tar -xzf "$ROOT/backups/plex-pre-migration.tgz" -C "$ROOT/restore-check"
(cd "$ROOT/restore-check" && sha256sum -c "$ROOT/manifests/plex.before.sha256") | tee "$ROOT/reports/restore-check.txt"
python3 - "$ROOT/restore-check/plex/config/Preferences.xml" <<'PY'
import sys
import xml.etree.ElementTree as ET
root = ET.parse(sys.argv[1]).getroot()
assert root.tag == "Preferences"
assert root.attrib["serverName"] == "Class59-Plex"
assert root.attrib["libraryPath"] == "/opt/lab-classroom/class59/media/library"
PY
7
Prepare and validate the Jellyfin candidate
The candidate receives a separate configuration and an explicit user mapping. The sample watch-state row is transformed into a candidate TSV. This demonstrates a mapping workflow; it does not imply that production Plex databases can be copied directly into Jellyfin.
install -d -m 0750 "$ROOT/state/jellyfin" "$ROOT/state/jellyfin/config" "$ROOT/state/jellyfin/data"
cat > "$ROOT/state/jellyfin/config/system.xml" <<'EOF'
EOF
printf 'source_user\ttarget_user\nlocal-alex\nalex\n' > "$ROOT/state/jellyfin/data/user-map.tsv"
awk -F '\t' 'BEGIN { OFS="\t" } NR==1 { print "target_user",$2,$3,$4; next } $1=="local-alex" { print "alex",$2,$3,$4 }' "$ROOT/state/plex/metadata/watch-state.tsv" > "$ROOT/state/jellyfin/data/watch-state.tsv"
python3 - "$ROOT/state/jellyfin/config/system.xml" <<'PY'
import sys
import xml.etree.ElementTree as ET
root = ET.parse(sys.argv[1]).getroot()
assert root.tag == "SystemConfiguration"
assert root.findtext("ServerName") == "Class59-Jellyfin"
assert root.findtext("MediaPath") == "/opt/lab-classroom/class59/media/library"
assert root.findtext("RemoteAccess") == "false"
PY
grep -Fx $'alex\tdemo-item\t120\t0' "$ROOT/state/jellyfin/data/watch-state.tsv"
(cd "$ROOT/state" && find jellyfin -type f -print0 | sort -z | xargs -0 sha256sum) > "$ROOT/manifests/jellyfin.candidate.sha256"
(cd "$ROOT/state" && sha256sum -c "$ROOT/manifests/jellyfin.candidate.sha256") | tee "$ROOT/reports/jellyfin-candidate-check.txt"
8
Perform the simulated cutover
The marker changes only after source backup, extraction, source validation, candidate validation, and media-permission testing have passed.
test "$(cat "$ROOT/cutover/active-service.before")" = plex
printf 'jellyfin\n' > "$ROOT/cutover/active-service"
test "$(cat "$ROOT/cutover/active-service")" = jellyfin
printf 'Simulated cutover selected Jellyfin\n' | tee "$ROOT/reports/cutover-result.txt"
9
Introduce source-state damage and execute rollback
Appending a line makes the current Plex checksum fail, providing a real recovery condition. Rollback preserves the failed directory with a fixed lab-only name, promotes the already verified extraction, and restores the active marker to Plex.
printf 'intentional-lab-damage\n' >> "$ROOT/state/plex/config/Preferences.xml"
if (cd "$ROOT/state" && sha256sum -c "$ROOT/manifests/plex.before.sha256") > "$ROOT/reports/expected-damage-check.txt" 2>&1; then
printf 'ERROR: intentional damage was not detected\n' >&2
exit 1
fi
test ! -e "$ROOT/state/plex.failed-after-cutover"
mv "$ROOT/state/plex" "$ROOT/state/plex.failed-after-cutover"
mv "$ROOT/restore-check/plex" "$ROOT/state/plex"
printf 'plex\n' > "$ROOT/cutover/active-service"
10
Verify rollback and independently extract the backup again
These checks prove that the historical before marker still names Plex, the active marker again names Plex, both manifests still validate, and the archive remains independently extractable after rollback.
test "$(cat "$ROOT/cutover/active-service.before")" = plex
test "$(cat "$ROOT/cutover/active-service")" = plex
(cd "$ROOT/state" && sha256sum -c "$ROOT/manifests/plex.before.sha256") | tee "$ROOT/reports/plex-after-rollback-check.txt"
(cd "$ROOT/state" && sha256sum -c "$ROOT/manifests/jellyfin.candidate.sha256") | tee "$ROOT/reports/jellyfin-after-rollback-check.txt"
install -d -m 0750 "$ROOT/verification-extract"
tar -xzf "$ROOT/backups/plex-pre-migration.tgz" -C "$ROOT/verification-extract"
(cd "$ROOT/verification-extract" && sha256sum -c "$ROOT/manifests/plex.before.sha256") | tee "$ROOT/reports/backup-post-rollback-extract-check.txt"
cmp "$ROOT/state/plex/config/Preferences.xml" "$ROOT/verification-extract/plex/config/Preferences.xml"
test -d "$ROOT/state/plex.failed-after-cutover"
grep -F 'intentional-lab-damage' "$ROOT/state/plex.failed-after-cutover/config/Preferences.xml"
printf 'CLASS59 COMPLETE: rollback and independent recovery verification passed\n' | tee "$ROOT/reports/final-status.txt"
| Symptom | Likely cause | Fix |
|---|---|---|
| Preflight fails because /opt/lab-classroom/class59 already exists. | A prior attempt or unrelated data occupies the required isolated path. | Do not overwrite it. Inspect and preserve it outside this lesson, then begin only when /opt/lab-classroom/class59 does not exist. |
| runuser reports that only root may specify a user. | The lab is not running in a root shell. | Restart the lab in a root Bash session. Do not replace the permission test with a text declaration because that would not prove least privilege. |
| The nobody read test returns permission denied. | A parent directory lacks execute permission for other users, or local mandatory access controls deny traversal. | Confirm that the class59, media, and media/library directories have the lesson modes and inspect local access-control logs. Keep all changes confined to the class59 workspace. |
| The nobody append unexpectedly succeeds. | The file or directory permissions differ from 0444 and 0555, or the test did not run as nobody. | Check id output inside runuser, inspect modes with stat, reapply the lesson modes, and repeat both the read and denied-write tests. |
| sha256sum -c reports a missing Plex file during restore validation. | The archive was incomplete, extracted into the wrong directory, or the manifest was generated from a different state. | Stop before cutover. List the archive with tar -tzf, confirm it contains paths beginning with plex/, and recreate the archive only from the validated source state. |
| A checksum fails immediately after manifest creation. | A file changed between manifest generation and verification, or the command was run from the wrong working directory. | Quiesce writers, run the check from /opt/lab-classroom/class59/state, and regenerate the manifest only after confirming the source is stable. |
| The expected damage check succeeds instead of failing. | The modified file was not included in the manifest or the append command targeted another path. | Confirm that plex/config/Preferences.xml appears in plex.before.sha256 and that the command uses the exact ROOT value. |
| The second archive extraction fails because verification-extract is not empty. | The step was already run in the same workspace. | Do not merge repeated extraction output. Preserve the current workspace for review and repeat the complete lab in a newly prepared class59 workspace. |
| The awk output lacks the alex watch-state row. | The source fixture delimiter or username was altered. | Inspect the source TSV with a tool that exposes tab characters and confirm the source identity is local-alex before rerunning the transformation. |
| Commands such as sort -z, stat -c, or find -print0 are unsupported. | The host uses non-GNU or reduced userland utilities. | Use the stated GNU-compatible Linux environment. Do not improvise filename-unsafe pipelines for production migrations. |
Keep media content separate from server databases and configuration so application recovery does not rewrite the media library.
Grant media-server identities read-only access when the server does not need to rename, delete, or create media.
Treat metadata agents, plug-ins, and migration tools as code with access to sensitive filenames, viewing history, account data, and credentials.
Do not assume Plex users, Jellyfin users, passwords, tokens, shares, or authorization rules are equivalent.
Disable or tightly control remote exposure until authentication, TLS, client behavior, logs, and recovery procedures have been validated.
Quiesce production database writes before file-level backup unless the product documentation explicitly supports live snapshots.
Protect backups because they may contain tokens, account records, library paths, viewing history, and identifying metadata.
Retain the failed state during rollback for investigation rather than immediately destroying evidence.
Validate archive contents before extraction, and extract untrusted archives only into an isolated destination with appropriate tooling.
Use supported export, API, or migration tools for real watch-state transfer; never insert guessed records directly into a production database.
The lesson does not expose network services, install plug-ins, alter host accounts, or access real media-server directories. Its unprivileged identity test reads one fixture and performs one intentionally denied append attempt.
The candidate cannot read required media.
A required client, codec, subtitle workflow, or accessibility feature fails acceptance testing.
Users or permissions are mapped incorrectly.
Backup extraction or checksum validation fails.
Unexpected writes occur in the media library.
The replacement service exposes an unapproved remote-access path.
Stop client cutover and prevent additional writes to the candidate.
Preserve the failed source or candidate state under a distinct name inside the controlled recovery area.
Promote only the Plex state that was extracted and checksum-verified before cutover.
Restore service selection to Plex.
Verify that active-service.before still records plex and active-service currently records plex.
Verify the Plex manifest from the restored state.
Verify the retained Jellyfin candidate manifest so rollback does not conceal unrelated candidate changes.
Extract the backup again into a new empty directory and verify it independently.
Document the failure and do not retry until its cause and acceptance criteria are understood.
The damaged Plex fixture remains at /opt/lab-classroom/class59/state/plex.failed-after-cutover, while the verified Plex copy is restored to /opt/lab-classroom/class59/state/plex.