From a36f058a5147f20e7a38fe88b1da00da82d0cf61 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 18:24:02 +0000 Subject: [PATCH] feat(media): add 6 media service modules (v0.9.10) Migrate the full media batch from the monolith: - jellyfin: auto-VAAPI on /dev/dri/renderD128 + render GID; DLNA/discovery UDP ports - emby: UID/GID baked at install; HW transcoding block commented for manual opt-in - audiobookshelf: separate audiobooks + podcasts paths; port 13378 - arm: optical drive detection; privileged:true; split movies/music output; port 8080 - lyrion: network_mode:host for Chromecast/Squeezebox UDP discovery; port 9000 - immich: multi-container stack (server+ML+valkey+postgres); two library strategies (unified with import-photos.sh helper, or external read-only); port 2283 https://claude.ai/code/session_01Y4dMKtkqkpvmgDKoRdzhTG --- CHANGELOG.md | 27 ++ VERSION | 2 +- services/arm.sh | 134 ++++++++ services/audiobookshelf.sh | 98 ++++++ services/emby.sh | 107 +++++++ services/immich.sh | 610 +++++++++++++++++++++++++++++++++++++ services/jellyfin.sh | 119 ++++++++ services/lyrion.sh | 109 +++++++ 8 files changed, 1205 insertions(+), 1 deletion(-) create mode 100644 services/arm.sh create mode 100644 services/audiobookshelf.sh create mode 100644 services/emby.sh create mode 100644 services/immich.sh create mode 100644 services/jellyfin.sh create mode 100644 services/lyrion.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index f806df5..6b87a6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ All notable changes to this project. Versions follow `MAJOR.MINOR.PATCH`. The project is pre-1.0 while the modular system reaches parity with the monolithic `ubuntu-post-install-*.sh` scripts. +## [0.9.10] - 2026-06-03 + +### Added +- **Media batch** — 6 service modules migrated from the monolith: + - `services/jellyfin.sh` *(media)* — Free media server (movies, TV, music). Auto-detects + `/dev/dri/renderD128` and enables VAAPI hardware transcoding with the render GID when + present; falls back to CPU transcoding otherwise. Ports 8096, 1900/udp (DLNA), + 7359/udp (discovery). + - `services/emby.sh` *(media)* — Emby media server. UID/GID baked from the install-time + user; HW transcoding block left commented (uncomment `/dev/dri` once GPU confirmed). + Ports 8096 (web) and 8920 (HTTPS). + - `services/audiobookshelf.sh` *(media)* — Audiobook & podcast server. Separate audiobooks + and podcasts paths; podcasts folder defaults to `./podcasts` inside the service dir. + Port 13378. + - `services/arm.sh` *(media)* — Automatic Ripping Machine for DVDs, Blu-rays, CDs. + Detects optical drives at install time (`/dev/sr*`); runs with `privileged: true`. + Ripped output split into movies/ and music/. Port 8080. + - `services/lyrion.sh` *(media)* — Lyrion Music Server (formerly LMS) for Squeezebox + devices, the Squeezer app, and Chromecast. Uses `network_mode: host` so UDP discovery + works without manual port mapping. Port 9000. + - `services/immich.sh` *(media)* — Self-hosted photo & video backup (like Google Photos). + Full multi-container stack (immich-server, immich-machine-learning, valkey/redis, + postgres). Two library strategies: (1) unified — all photos in one place with an + auto-generated `import-photos.sh` helper that handles admin account creation, API key + generation, storage template config, and CLI upload; (2) external — existing photos + indexed read-only, new uploads separate. Port 2283. + ## [0.9.9] - 2026-06-03 ### Added diff --git a/VERSION b/VERSION index 7e310ba..56f3151 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.9 +0.9.10 diff --git a/services/arm.sh b/services/arm.sh new file mode 100644 index 0000000..3d84efa --- /dev/null +++ b/services/arm.sh @@ -0,0 +1,134 @@ +#!/bin/bash +# services/arm.sh — Automatic Ripping Machine: rip DVDs, Blu-rays, CDs. +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- A.R.M. ----). +# Own ~/docker/arm/ with a standalone docker-compose.yml + .env. Detects +# optical drives at install time; add more /dev/srN entries manually after. + +register_service arm media "Automatic Ripping Machine — rip DVDs, Blu-rays, CDs" 8080 + +install_arm() { + require_docker || return 1 + + local ARM_DIR="$DOCKER_DIR/arm" + local DEFAULT_OUTPUT="$ACTUAL_HOME/ripped" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] A.R.M. would:" + echo " - Create $ARM_DIR with docker-compose.yml + .env (config/ logs/)" + echo " - Detect optical drives (/dev/sr*) — defaults to /dev/sr0" + echo " - Create ripped output dirs (movies/ music/) under $DEFAULT_OUTPUT" + echo " - Run as UID/GID $(id -u "$ACTUAL_USER")/$(id -g "$ACTUAL_USER") with privileged: true" + echo " - Expose port 8080" + echo " - Offer a Caddy reverse proxy and to start the container" + return 0 + fi + + local ARM_OUTPUT="" + prompt_text "Path for ripped media output [$DEFAULT_OUTPUT]:" "$DEFAULT_OUTPUT" ARM_OUTPUT + ARM_OUTPUT="${ARM_OUTPUT/#\~/$ACTUAL_HOME}"; ARM_OUTPUT="${ARM_OUTPUT%/}" + + echo "" + echo "Detecting optical drives..." + local OPTICAL_DRIVES + OPTICAL_DRIVES=$(ls /dev/sr* 2>/dev/null || true) + if [ -n "$OPTICAL_DRIVES" ]; then + echo " Found: $OPTICAL_DRIVES" + else + echo " No optical drives detected. Defaulting to /dev/sr0 — add more later." + OPTICAL_DRIVES="/dev/sr0" + fi + + mkdir -p "$ARM_DIR" + ensure_docker_dir_ownership "$ARM_DIR" + cd "$ARM_DIR" || return 1 + + local TZ_VAL UID_VAL GID_VAL + TZ_VAL=$(cat /etc/timezone 2>/dev/null || echo "UTC") + UID_VAL=$(id -u "$ACTUAL_USER"); GID_VAL=$(id -g "$ACTUAL_USER") + + cat > docker-compose.yml << ARM_COMPOSE +name: arm + +services: + automatic-ripping-machine: + image: automaticrippingmachine/automatic-ripping-machine:latest + container_name: arm + hostname: arm + restart: unless-stopped + environment: + - ARM_UID=$UID_VAL + - ARM_GID=$GID_VAL + - TZ=$TZ_VAL + volumes: + - ./config:/etc/arm/config + - ./logs:/home/arm/logs + - \${ARM_OUTPUT}/movies:/home/arm/media/completed + - \${ARM_OUTPUT}/music:/home/arm/music + ports: + - "8080:8080" + devices: + - /dev/sr0:/dev/sr0 + # Add more optical drives as needed: + # - /dev/sr1:/dev/sr1 + privileged: true +ARM_COMPOSE + + cat > .env << ARM_ENV +ARM_OUTPUT=$ARM_OUTPUT +ARM_ENV + + mkdir -p config logs + mkdir -p "$ARM_OUTPUT/movies" "$ARM_OUTPUT/music" + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ARM_DIR" + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ARM_OUTPUT" + log_success "A.R.M. configured at $ARM_DIR" + + configure_caddy_for_service "A.R.M." "8080" "arm" + + write_readme "$ARM_DIR" << MD +# A.R.M. (Automatic Ripping Machine) + +Auto-rips DVDs, Blu-rays, and CDs when you insert them — identifies the disc, +fetches metadata, and transcodes to a usable format. + +- Web UI: http://localhost:8080 (complete setup on first visit) +- Ripped output: \`$ARM_OUTPUT\` → movies and music subdirs +- App data: \`config/\` and \`logs/\` + +## Manage +\`\`\`bash +cd $ARM_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## Adding optical drives +Edit \`docker-compose.yml\` and add more \`devices:\` entries: +\`\`\`yaml + devices: + - /dev/sr0:/dev/sr0 + - /dev/sr1:/dev/sr1 +\`\`\` +Then \`docker compose up -d\` to apply. + +## Notes +- First launch: open the web UI and complete the setup wizard. +- \`privileged: true\` is required for ARM to control the optical drive. +- Change the output path in \`.env\` (\`ARM_OUTPUT=\`), then \`docker compose up -d\`. +MD + + local START_ARM="" + prompt_yn "Start A.R.M. now? (y/n):" "y" START_ARM + if [ "$START_ARM" = "y" ] || [ "$START_ARM" = "Y" ]; then + docker compose up -d && log_success "A.R.M. started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Access at: http://localhost:8080" + echo " Complete setup in browser on first visit." + echo "" +} diff --git a/services/audiobookshelf.sh b/services/audiobookshelf.sh new file mode 100644 index 0000000..6149f6f --- /dev/null +++ b/services/audiobookshelf.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# services/audiobookshelf.sh — Audiobook & podcast server (Audiobookshelf). +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- AUDIOBOOKSHELF ----). +# Own ~/docker/audiobookshelf/ with a standalone docker-compose.yml + .env. + +register_service audiobookshelf media "Audiobook & podcast server (Audiobookshelf)" 13378 + +install_audiobookshelf() { + require_docker || return 1 + + local ABS_DIR="$DOCKER_DIR/audiobookshelf" + local DEFAULT_AUDIOBOOKS="$ACTUAL_HOME/audiobooks" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Audiobookshelf would:" + echo " - Create $ABS_DIR with docker-compose.yml + .env (config/ metadata/ podcasts/)" + echo " - Mount an audiobooks folder (default $DEFAULT_AUDIOBOOKS) at /audiobooks" + echo " - Expose port 13378" + echo " - Offer a Caddy reverse proxy and to start the container" + return 0 + fi + + local AUDIOBOOKS_PATH="" + prompt_text "Path to audiobooks folder [$DEFAULT_AUDIOBOOKS]:" "$DEFAULT_AUDIOBOOKS" AUDIOBOOKS_PATH + AUDIOBOOKS_PATH="${AUDIOBOOKS_PATH/#\~/$ACTUAL_HOME}"; AUDIOBOOKS_PATH="${AUDIOBOOKS_PATH%/}" + + mkdir -p "$ABS_DIR" + ensure_docker_dir_ownership "$ABS_DIR" + cd "$ABS_DIR" || return 1 + + local TZ_VAL; TZ_VAL=$(cat /etc/timezone 2>/dev/null || echo "UTC") + + cat > docker-compose.yml << ABS_COMPOSE +name: audiobookshelf + +services: + audiobookshelf: + image: ghcr.io/advplyr/audiobookshelf:latest + container_name: audiobookshelf + hostname: audiobookshelf + restart: unless-stopped + environment: + - TZ=$TZ_VAL + volumes: + - ./config:/config + - ./metadata:/metadata + - \${AUDIOBOOKS_PATH}:/audiobooks + - \${PODCASTS_PATH:-./podcasts}:/podcasts + ports: + - "13378:80" +ABS_COMPOSE + + cat > .env << ABS_ENV +AUDIOBOOKS_PATH=$AUDIOBOOKS_PATH +PODCASTS_PATH=./podcasts +ABS_ENV + + mkdir -p config metadata podcasts + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ABS_DIR" + log_success "Audiobookshelf configured at $ABS_DIR" + + configure_caddy_for_service "AudioBookshelf" "13378" "audiobooks" + + write_readme "$ABS_DIR" << MD +# Audiobookshelf + +Self-hosted audiobook and podcast server with progress sync across devices. + +- Web UI: http://localhost:13378 +- Audiobooks: \`$AUDIOBOOKS_PATH\` → mounted at /audiobooks +- Podcasts: \`podcasts/\` in this folder → /podcasts (change \`PODCASTS_PATH\` in .env) +- App data: \`config/\` and \`metadata/\` + +## Manage +\`\`\`bash +cd $ABS_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +First launch: open the web UI, create your admin account, then add libraries +pointing at /audiobooks and /podcasts. +MD + + local START_ABS="" + prompt_yn "Start Audiobookshelf now? (y/n):" "y" START_ABS + if [ "$START_ABS" = "y" ] || [ "$START_ABS" = "Y" ]; then + docker compose up -d && log_success "Audiobookshelf started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Access at: http://localhost:13378" + echo "" +} diff --git a/services/emby.sh b/services/emby.sh new file mode 100644 index 0000000..280c31a --- /dev/null +++ b/services/emby.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# services/emby.sh — Media server for movies, TV, and music (Emby). +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- EMBY ----). +# Own ~/docker/emby/ with a standalone docker-compose.yml + .env. Hardware +# transcoding is left commented in the compose (uncomment the /dev/dri block +# once you've confirmed your GPU) to match the original behavior. + +register_service emby media "Media server — movies, TV, music (Emby)" 8096 + +install_emby() { + require_docker || return 1 + + local EMBY_DIR="$DOCKER_DIR/emby" + local DEFAULT_MEDIA="$ACTUAL_HOME/media" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Emby would:" + echo " - Create $EMBY_DIR with docker-compose.yml + .env (config/)" + echo " - Mount a media folder (default $DEFAULT_MEDIA) at /media" + echo " - Run as UID/GID $(id -u "$ACTUAL_USER")/$(id -g "$ACTUAL_USER")" + echo " - Expose ports 8096 (web) and 8920 (https)" + echo " - Offer a Caddy reverse proxy and to start the container" + return 0 + fi + + local MEDIA_PATH="" + prompt_text "Path to media folder [$DEFAULT_MEDIA]:" "$DEFAULT_MEDIA" MEDIA_PATH + MEDIA_PATH="${MEDIA_PATH/#\~/$ACTUAL_HOME}"; MEDIA_PATH="${MEDIA_PATH%/}" + + mkdir -p "$EMBY_DIR" + ensure_docker_dir_ownership "$EMBY_DIR" + cd "$EMBY_DIR" || return 1 + + local TZ_VAL UID_VAL GID_VAL + TZ_VAL=$(cat /etc/timezone 2>/dev/null || echo "UTC") + UID_VAL=$(id -u "$ACTUAL_USER"); GID_VAL=$(id -g "$ACTUAL_USER") + + cat > docker-compose.yml << EMBY_COMPOSE +name: emby + +services: + emby: + image: emby/embyserver:latest + container_name: emby + hostname: emby + restart: unless-stopped + environment: + - UID=$UID_VAL + - GID=$GID_VAL + - TZ=$TZ_VAL + volumes: + - ./config:/config + - \${MEDIA_PATH}:/media + ports: + - "8096:8096" + - "8920:8920" + # Uncomment for hardware transcoding (Intel/AMD): + # devices: + # - /dev/dri:/dev/dri +EMBY_COMPOSE + + cat > .env << EMBY_ENV +MEDIA_PATH=$MEDIA_PATH +EMBY_ENV + + mkdir -p config + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EMBY_DIR" + log_success "Emby configured at $EMBY_DIR" + + configure_caddy_for_service "Emby" "8096" "emby" + + write_readme "$EMBY_DIR" << MD +# Emby + +Media server for movies, TV, and music. + +- Web UI: http://localhost:8096 (HTTPS on 8920) +- Media folder: \`$MEDIA_PATH\` → mounted at /media +- App data: \`config/\` in this folder +- Edit the media path in \`.env\` (\`MEDIA_PATH=\`), then \`docker compose up -d\`. + +## Manage +\`\`\`bash +cd $EMBY_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## Hardware transcoding +Uncomment the \`devices: [/dev/dri:/dev/dri]\` block in \`docker-compose.yml\` +once you've confirmed your Intel/AMD GPU exposes a render node, then restart. +MD + + local START_EMBY="" + prompt_yn "Start Emby now? (y/n):" "y" START_EMBY + if [ "$START_EMBY" = "y" ] || [ "$START_EMBY" = "Y" ]; then + docker compose up -d && log_success "Emby started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Access at: http://localhost:8096" + echo "" +} diff --git a/services/immich.sh b/services/immich.sh new file mode 100644 index 0000000..0bce477 --- /dev/null +++ b/services/immich.sh @@ -0,0 +1,610 @@ +#!/bin/bash +# services/immich.sh — Self-hosted photo & video backup (like Google Photos). +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- IMMICH ----). +# Multi-container stack: immich-server + machine-learning + valkey + postgres. +# Two library strategies: +# 1) Unified — Immich manages all photos in one place (import-photos.sh helps) +# 2) External — Immich indexes your existing folder read-only; new uploads separate + +register_service immich media "Self-hosted photo & video backup — like Google Photos (Immich)" 2283 + +install_immich() { + require_docker || return 1 + + local IMMICH_DIR="$DOCKER_DIR/immich" + local DEFAULT_PHOTOS="$ACTUAL_HOME/photos" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Immich would:" + echo " - Create $IMMICH_DIR with docker-compose.yml + .env" + echo " - Deploy: immich-server, immich-machine-learning, valkey, postgres" + echo " - Strategy 1 (unified): all photos in one folder, import-photos.sh helper" + echo " - Strategy 2 (external): existing photos indexed read-only, new uploads separate" + echo " - Expose port 2283" + echo " - Offer a Caddy reverse proxy and to start the stack" + return 0 + fi + + # ── Photo library setup ───────────────────────────────────────────────── + echo "" + echo " PHOTO LIBRARY SETUP" + echo "" + + local IMMICH_STRATEGY="1" UPLOAD_LOCATION="" EXTERNAL_LIBRARY="" EXISTING_PHOTOS_SOURCE="" + local HAS_EXISTING_PHOTOS="" + prompt_yn "Do you have existing photos to include? (y/n):" "n" HAS_EXISTING_PHOTOS + + if [ "$HAS_EXISTING_PHOTOS" = "y" ] || [ "$HAS_EXISTING_PHOTOS" = "Y" ]; then + echo "" + echo " How should Immich handle your existing photos?" + echo "" + echo " [1] Import into Immich (recommended)" + echo " Immich manages all photos in one unified library." + echo " Dates preserved via EXIF. Organized by date automatically." + echo " Your original folder names are NOT kept on disk" + echo " (use Immich albums to organize instead)." + echo "" + echo " [2] Keep existing photos in place (read-only external library)" + echo " Immich indexes your existing photos without moving them." + echo " New uploads go to a separate folder." + echo " Your folder structure stays intact." + echo "" + + if [ "$UNATTENDED" = true ]; then + IMMICH_STRATEGY="1" + echo " Strategy: [auto: 1]" + else + read -r -p " Choose [1/2]: " IMMICH_STRATEGY + IMMICH_STRATEGY="${IMMICH_STRATEGY:-1}" + fi + + if [ "$IMMICH_STRATEGY" = "2" ]; then + local EXISTING_PHOTOS_PATH="" + prompt_text "Existing photos path [$DEFAULT_PHOTOS]:" "$DEFAULT_PHOTOS" EXISTING_PHOTOS_PATH + EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_PATH/#\~/$ACTUAL_HOME}"; EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE%/}" + UPLOAD_LOCATION="$ACTUAL_HOME/immich-uploads" + EXTERNAL_LIBRARY="$EXISTING_PHOTOS_SOURCE" + echo "" + echo " Setup:" + echo " Existing photos: $EXISTING_PHOTOS_SOURCE (read-only)" + echo " New uploads: $UPLOAD_LOCATION" + else + local PHOTOS_DIR_INPUT="" + prompt_text "Photo library path [$DEFAULT_PHOTOS]:" "$DEFAULT_PHOTOS" PHOTOS_DIR_INPUT + PHOTOS_DIR_INPUT="${PHOTOS_DIR_INPUT/#\~/$ACTUAL_HOME}"; PHOTOS_DIR_INPUT="${PHOTOS_DIR_INPUT%/}" + + local EXISTING_INPUT="" + prompt_text "Existing photos path [$PHOTOS_DIR_INPUT]:" "$PHOTOS_DIR_INPUT" EXISTING_INPUT + EXISTING_PHOTOS_SOURCE="${EXISTING_INPUT/#\~/$ACTUAL_HOME}"; EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE%/}" + + UPLOAD_LOCATION="$PHOTOS_DIR_INPUT" + echo "" + echo " All photos (existing + new) will live in: $PHOTOS_DIR_INPUT" + fi + else + local PHOTOS_DIR_INPUT="" + prompt_text "Photo library path [$DEFAULT_PHOTOS]:" "$DEFAULT_PHOTOS" PHOTOS_DIR_INPUT + PHOTOS_DIR_INPUT="${PHOTOS_DIR_INPUT/#\~/$ACTUAL_HOME}"; PHOTOS_DIR_INPUT="${PHOTOS_DIR_INPUT%/}" + UPLOAD_LOCATION="$PHOTOS_DIR_INPUT" + echo "" + echo " Photos will be stored in: $PHOTOS_DIR_INPUT" + fi + + echo "" + + # ── Create directories ────────────────────────────────────────────────── + mkdir -p "$IMMICH_DIR" + ensure_docker_dir_ownership "$IMMICH_DIR" + mkdir -p "$UPLOAD_LOCATION" + [ -n "$EXTERNAL_LIBRARY" ] && mkdir -p "$EXTERNAL_LIBRARY" + + # Immich checks for these subdirs + .immich marker files on startup + local subdir + for subdir in thumbs upload backups library profile encoded-video; do + mkdir -p "$UPLOAD_LOCATION/$subdir" + touch "$UPLOAD_LOCATION/$subdir/.immich" + done + + cd "$IMMICH_DIR" || return 1 + + # ── Generate DB password ──────────────────────────────────────────────── + local DB_PASS TZ_VAL + DB_PASS=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32) + TZ_VAL=$(cat /etc/timezone 2>/dev/null || echo "UTC") + + # ── Write docker-compose.yml ──────────────────────────────────────────── + if [ -n "$EXTERNAL_LIBRARY" ]; then + cat > docker-compose.yml << 'IMMICH_COMPOSE' +name: immich + +services: + immich-server: + container_name: immich_server + image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} + volumes: + - ${UPLOAD_LOCATION}:/usr/src/app/upload + - ${EXTERNAL_LIBRARY}:/usr/src/app/external:ro + - /etc/localtime:/etc/localtime:ro + env_file: + - .env + ports: + - 2283:2283 + depends_on: + - redis + - database + restart: always + healthcheck: + disable: false + + immich-machine-learning: + container_name: immich_machine_learning + image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release} + volumes: + - model-cache:/cache + env_file: + - .env + restart: always + healthcheck: + disable: false + + redis: + container_name: immich_redis + image: docker.io/valkey/valkey:9-bookworm + healthcheck: + test: valkey-cli ping || exit 1 + restart: always + + database: + container_name: immich_postgres + image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_DB: ${DB_DATABASE_NAME} + POSTGRES_INITDB_ARGS: '--data-checksums' + volumes: + - ${DB_DATA_LOCATION}:/var/lib/postgresql/data + restart: always + +volumes: + model-cache: +IMMICH_COMPOSE + else + cat > docker-compose.yml << 'IMMICH_COMPOSE' +name: immich + +services: + immich-server: + container_name: immich_server + image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} + volumes: + - ${UPLOAD_LOCATION}:/usr/src/app/upload + - /etc/localtime:/etc/localtime:ro + env_file: + - .env + ports: + - 2283:2283 + depends_on: + - redis + - database + restart: always + healthcheck: + disable: false + + immich-machine-learning: + container_name: immich_machine_learning + image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release} + volumes: + - model-cache:/cache + env_file: + - .env + restart: always + healthcheck: + disable: false + + redis: + container_name: immich_redis + image: docker.io/valkey/valkey:9-bookworm + healthcheck: + test: valkey-cli ping || exit 1 + restart: always + + database: + container_name: immich_postgres + image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_DB: ${DB_DATABASE_NAME} + POSTGRES_INITDB_ARGS: '--data-checksums' + volumes: + - ${DB_DATA_LOCATION}:/var/lib/postgresql/data + restart: always + +volumes: + model-cache: +IMMICH_COMPOSE + fi + + # ── Write .env ────────────────────────────────────────────────────────── + if [ "$IMMICH_STRATEGY" = "2" ]; then + cat > .env << IMMICH_ENV +# IMMICH CONFIGURATION — External Library Mode +# +# STORAGE TEMPLATE (set in Immich web UI): +# Admin → Settings → Storage Template → Enable +# Template: {{y}}/{{MM}}/{{filename}} +# +# EXTERNAL LIBRARY SETUP: +# Admin → External Libraries → Create Library +# Import path: /usr/src/app/external +# Click "Scan" to index your existing photos. + +# New uploads from phone/web +UPLOAD_LOCATION=$UPLOAD_LOCATION + +# Existing photos (read-only, indexed by Immich) +EXTERNAL_LIBRARY=$EXTERNAL_LIBRARY + +DB_DATA_LOCATION=./postgres +IMMICH_VERSION=release +DB_PASSWORD=$DB_PASS +DB_USERNAME=postgres +DB_DATABASE_NAME=immich +TZ=$TZ_VAL +IMMICH_ENV + else + cat > .env << IMMICH_ENV +# IMMICH CONFIGURATION — Unified Library +# +# All photos (imported + new uploads) are stored in one location. +# Storage template organizes files by date automatically. +# +# To import existing photos run: $IMMICH_DIR/import-photos.sh + +UPLOAD_LOCATION=$UPLOAD_LOCATION +DB_DATA_LOCATION=./postgres +IMMICH_VERSION=release +DB_PASSWORD=$DB_PASS +DB_USERNAME=postgres +DB_DATABASE_NAME=immich +TZ=$TZ_VAL +IMMICH_ENV + fi + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR" + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$UPLOAD_LOCATION" + [ -n "$EXTERNAL_LIBRARY" ] && chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EXTERNAL_LIBRARY" 2>/dev/null || true + + # ── import-photos.sh (strategy 1 + existing photos only) ─────────────── + if [ "$IMMICH_STRATEGY" != "2" ] && [ -n "$EXISTING_PHOTOS_SOURCE" ]; then + cat > "$IMMICH_DIR/import-photos.sh" << 'IMPORT_HEAD' +#!/bin/bash +################################################################################ +# Immich Photo Import Script — generated by ubuntu-post-install +# +# Imports your existing photo collection into Immich with EXIF date preservation. +# Photos are uploaded through the API so Immich extracts metadata (dates, GPS, +# camera info) from the originals. +# +# What this script does: +# 1. Creates admin account (if first run) or logs in +# 2. Generates an API key automatically +# 3. Configures the storage template (date-based organization) +# 4. Installs the Immich CLI (if needed) +# 5. Uploads all photos with EXIF metadata preserved +# +# Usage: +# ./import-photos.sh # interactive (prompts for everything) +# ./import-photos.sh # skip account setup, use existing key +################################################################################ + +IMPORT_HEAD + + cat >> "$IMMICH_DIR/import-photos.sh" << IMPORT_VARS +IMMICH_URL="http://localhost:2283" +SOURCE_DIR="$EXISTING_PHOTOS_SOURCE" +IMMICH_DIR="$IMMICH_DIR" +IMPORT_VARS + + cat >> "$IMMICH_DIR/import-photos.sh" << 'IMPORT_BODY' + +echo "" +echo "┌─────────────────────────────────────────────────────────────────┐" +echo "│ IMMICH PHOTO IMPORT │" +echo "└─────────────────────────────────────────────────────────────────┘" +echo "" + +# ── Preflight checks ──────────────────────────────────────────────────────── +echo "Checking Immich server..." +if ! curl -s "$IMMICH_URL/api/server/ping" > /dev/null 2>&1; then + echo "" + echo " ✗ Immich is not running at $IMMICH_URL" + echo " Start it with: cd $IMMICH_DIR && docker compose up -d" + echo "" + exit 1 +fi +echo " ✓ Immich is running" + +if [ ! -d "$SOURCE_DIR" ]; then + echo "" + echo " ✗ Source directory not found: $SOURCE_DIR" + echo " Update SOURCE_DIR in this script if your photos moved." + echo "" + exit 1 +fi +echo " ✓ Source directory: $SOURCE_DIR" + +echo -n " Scanning for photos/videos..." +PHOTO_COUNT=$(find "$SOURCE_DIR" -type f \( \ + -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.heic" -o \ + -iname "*.heif" -o -iname "*.webp" -o -iname "*.gif" -o -iname "*.tiff" -o \ + -iname "*.bmp" -o -iname "*.mp4" -o -iname "*.mov" -o -iname "*.avi" -o \ + -iname "*.mkv" -o -iname "*.webm" \) 2>/dev/null | wc -l) +echo " done" +echo " ✓ Found ~$PHOTO_COUNT photos/videos" + +# ── Get or create API key ──────────────────────────────────────────────────── +API_KEY="${1:-}" + +if [ -z "$API_KEY" ]; then + echo "" + SERVER_CONFIG=$(curl -s "$IMMICH_URL/api/server/config" 2>/dev/null) + IS_INITIALIZED=$(echo "$SERVER_CONFIG" | python3 -c \ + "import sys,json; print(json.load(sys.stdin).get('isInitialized', True))" 2>/dev/null) + + if [ "$IS_INITIALIZED" = "False" ]; then + echo "┌─────────────────────────────────────────────────────────────────┐" + echo "│ FIRST-TIME SETUP — Creating admin account │" + echo "└─────────────────────────────────────────────────────────────────┘" + echo "" + read -r -p " Admin email: " ADMIN_EMAIL + while [ -z "$ADMIN_EMAIL" ]; do + read -r -p " Admin email (required): " ADMIN_EMAIL + done + read -r -sp " Admin password: " ADMIN_PASS; echo "" + while [ "${#ADMIN_PASS}" -lt 8 ]; do + echo " Password must be at least 8 characters." + read -r -sp " Admin password: " ADMIN_PASS; echo "" + done + read -r -p " Your name [Admin]: " ADMIN_NAME + ADMIN_NAME="${ADMIN_NAME:-Admin}" + + echo "" + echo " Creating admin account..." + SIGNUP_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ + -H "Content-Type: application/json" \ + "$IMMICH_URL/api/auth/admin-sign-up" \ + -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\",\"name\":\"$ADMIN_NAME\"}" 2>/dev/null) + SIGNUP_CODE=$(echo "$SIGNUP_RESPONSE" | tail -1) + SIGNUP_BODY=$(echo "$SIGNUP_RESPONSE" | sed '$d') + + if [ "$SIGNUP_CODE" = "201" ]; then + echo " ✓ Admin account created" + else + echo " ✗ Failed to create admin account (HTTP $SIGNUP_CODE)" + echo " Response: $SIGNUP_BODY" + echo " Create your account at $IMMICH_URL then re-run: $0 " + exit 1 + fi + else + echo " Immich is already set up. Log in to generate an API key." + echo "" + read -r -p " Admin email: " ADMIN_EMAIL + while [ -z "$ADMIN_EMAIL" ]; do + read -r -p " Admin email (required): " ADMIN_EMAIL + done + read -r -sp " Admin password: " ADMIN_PASS; echo "" + fi + + echo " Logging in..." + LOGIN_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ + -H "Content-Type: application/json" \ + "$IMMICH_URL/api/auth/login" \ + -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\"}" 2>/dev/null) + LOGIN_CODE=$(echo "$LOGIN_RESPONSE" | tail -1) + LOGIN_BODY=$(echo "$LOGIN_RESPONSE" | sed '$d') + + if [ "$LOGIN_CODE" != "201" ]; then + echo " ✗ Login failed (HTTP $LOGIN_CODE)" + echo " Check your email/password, or pass an API key: $0 " + exit 1 + fi + + ACCESS_TOKEN=$(echo "$LOGIN_BODY" | python3 -c \ + "import sys,json; print(json.load(sys.stdin)['accessToken'])" 2>/dev/null) + [ -z "$ACCESS_TOKEN" ] && { echo " ✗ Could not extract access token"; exit 1; } + echo " ✓ Logged in" + + echo " Creating API key..." + APIKEY_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + "$IMMICH_URL/api/api-keys" \ + -d '{"name":"import-photos-script"}' 2>/dev/null) + APIKEY_CODE=$(echo "$APIKEY_RESPONSE" | tail -1) + APIKEY_BODY=$(echo "$APIKEY_RESPONSE" | sed '$d') + + if [ "$APIKEY_CODE" = "201" ]; then + API_KEY=$(echo "$APIKEY_BODY" | python3 -c \ + "import sys,json; print(json.load(sys.stdin)['secret'])" 2>/dev/null) + if [ -n "$API_KEY" ]; then + echo " ✓ API key created" + else + echo " ✗ Could not extract API key" + echo " Create one at $IMMICH_URL → Account Settings → API Keys" + echo " Then re-run: $0 " + exit 1 + fi + else + echo " ✗ Failed to create API key (HTTP $APIKEY_CODE)" + echo " Create one at $IMMICH_URL → Account Settings → API Keys" + echo " Then re-run: $0 " + exit 1 + fi +else + echo "" + echo " Verifying API key..." + VERIFY_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "x-api-key: $API_KEY" "$IMMICH_URL/api/users/me" 2>/dev/null) + [ "$VERIFY_CODE" != "200" ] && { echo " ✗ Invalid API key (HTTP $VERIFY_CODE)"; exit 1; } + echo " ✓ API key valid" +fi + +# ── Configure storage template ─────────────────────────────────────────────── +echo "" +echo " Configuring storage template ({{y}}/{{MM}}/{{filename}})..." +CURRENT_CONFIG=$(curl -s -H "x-api-key: $API_KEY" "$IMMICH_URL/api/system-config" 2>/dev/null) +if [ -n "$CURRENT_CONFIG" ] && command -v python3 &>/dev/null; then + UPDATED_CONFIG=$(echo "$CURRENT_CONFIG" | python3 -c " +import sys, json +config = json.load(sys.stdin) +config['storageTemplate']['enabled'] = True +config['storageTemplate']['template'] = '{{y}}/{{MM}}/{{filename}}' +json.dump(config, sys.stdout) +" 2>/dev/null) + if [ -n "$UPDATED_CONFIG" ]; then + RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \ + -H "x-api-key: $API_KEY" \ + -H "Content-Type: application/json" \ + "$IMMICH_URL/api/system-config" \ + -d "$UPDATED_CONFIG" 2>/dev/null) + [ "$RESULT" = "200" ] \ + && echo " ✓ Storage template configured" \ + || echo " ⚠ Could not set template (HTTP $RESULT) — set manually in Admin → Settings" + else + echo " ⚠ Could not parse config — set storage template manually in Admin → Settings" + fi +else + echo " ⚠ python3 not found — set storage template manually in Admin → Settings" +fi + +# ── Install immich-cli if needed ───────────────────────────────────────────── +echo "" +IMMICH_CMD="" +NODE_OK=false +if command -v node &>/dev/null; then + NODE_MAJOR=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1) + [ "$NODE_MAJOR" -ge 20 ] 2>/dev/null && NODE_OK=true +fi + +if [ "$NODE_OK" = false ]; then + echo " Immich CLI requires Node.js >= 20 (found: $(node -v 2>/dev/null || echo 'none'))." + read -r -p " Install Node.js 22 LTS now? (y/n): " INSTALL_NODE_YN + if [ "$INSTALL_NODE_YN" = "y" ] || [ "$INSTALL_NODE_YN" = "Y" ]; then + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - 2>/dev/null + sudo apt-get install -y -qq nodejs 2>/dev/null + NODE_MAJOR=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1) + if [ "$NODE_MAJOR" -ge 20 ] 2>/dev/null; then + NODE_OK=true + echo " ✓ Node.js $(node -v) installed" + else + echo " ✗ Installation failed — install Node.js 20+ manually then re-run: $0 $API_KEY" + exit 1 + fi + else + echo " Install Node.js 20+ and re-run: $0 $API_KEY" + exit 0 + fi +fi + +if command -v immich &>/dev/null; then + IMMICH_CMD="immich" + echo " ✓ Immich CLI found" +elif command -v npx &>/dev/null; then + echo " Immich CLI not installed — will use npx." + IMMICH_CMD="npx --yes @immich/cli" +elif command -v npm &>/dev/null; then + echo " Installing Immich CLI globally..." + if npm install -g @immich/cli 2>/dev/null; then + IMMICH_CMD="immich" + echo " ✓ Immich CLI installed" + else + IMMICH_CMD="npx --yes @immich/cli" + fi +fi + +[ -z "$IMMICH_CMD" ] && { echo " ✗ No npm/npx found — install manually: npm install -g @immich/cli"; exit 1; } + +# ── Run the import ─────────────────────────────────────────────────────────── +echo "" +echo " Authenticating CLI..." +$IMMICH_CMD login "$IMMICH_URL/api" "$API_KEY" || { echo " ✗ CLI login failed"; exit 1; } + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " Starting import from: $SOURCE_DIR" +echo " Importing ~$PHOTO_COUNT files. This may take a while." +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +$IMMICH_CMD upload --recursive "$SOURCE_DIR" + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " Import complete! View your photos at: $IMMICH_URL" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +IMPORT_BODY + + chmod +x "$IMMICH_DIR/import-photos.sh" + chown "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR/import-photos.sh" + log_success "Import helper written: $IMMICH_DIR/import-photos.sh" + fi + + log_success "Immich configured at $IMMICH_DIR" + + configure_caddy_for_service "Immich" "2283" "immich" + + write_readme "$IMMICH_DIR" << MD +# Immich + +Self-hosted photo and video backup — like Google Photos but private. +Mobile apps (iOS/Android) auto-upload in the background. + +- Web UI: http://localhost:2283 +- Photo storage: \`$UPLOAD_LOCATION\` +- App data (postgres, model cache): inside this folder +- Edit paths in \`.env\`, then \`docker compose up -d\` to apply. + +## Manage +\`\`\`bash +cd $IMMICH_DIR +docker compose up -d # start all containers +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## First launch +1. Open http://localhost:2283 and create your admin account. +2. Install the Immich mobile app and point it at \`http://:2283\`. +3. (External library mode) Go to Admin → External Libraries → Create Library, + set import path to \`/usr/src/app/external\`, and click Scan. + +## Import existing photos (unified mode) +\`\`\`bash +./import-photos.sh # interactive +./import-photos.sh # skip login, use existing API key +\`\`\` + +## Notes +- Machine learning features (face recognition, CLIP search) require the + \`immich-machine-learning\` container — it pulls a large model on first run. +- The \`.immich\` marker files in the upload subdirs are required by Immich; + do not delete them. +MD + + local START_IMMICH="" + prompt_yn "Start Immich now? (y/n):" "y" START_IMMICH + if [ "$START_IMMICH" = "y" ] || [ "$START_IMMICH" = "Y" ]; then + docker compose up -d && log_success "Immich started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Access at: http://localhost:2283" + echo " First launch: create your admin account in the web UI." + echo "" +} diff --git a/services/jellyfin.sh b/services/jellyfin.sh new file mode 100644 index 0000000..8211150 --- /dev/null +++ b/services/jellyfin.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# services/jellyfin.sh — Free media server for movies, TV, and music (Jellyfin). +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- JELLYFIN ----). +# Lives in its own ~/docker/jellyfin/ with a standalone docker-compose.yml + .env. +# Hardware transcoding (Intel/AMD VAAPI) is auto-enabled when a render node +# (/dev/dri/renderD128) is present on the host. + +register_service jellyfin media "Free media server — movies, TV, music (Jellyfin)" 8096 + +install_jellyfin() { + require_docker || return 1 + + local JELLYFIN_DIR="$DOCKER_DIR/jellyfin" + local DEFAULT_MEDIA="$ACTUAL_HOME/media" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Jellyfin would:" + echo " - Create $JELLYFIN_DIR with docker-compose.yml + .env (config/ cache/)" + echo " - Mount a media folder (default $DEFAULT_MEDIA) read-only at /media" + echo " - Auto-enable VAAPI hw transcoding if /dev/dri/renderD128 exists" + echo " - Expose port 8096 (+ DLNA 1900/udp, discovery 7359/udp)" + echo " - Offer a Caddy reverse proxy and to start the container" + return 0 + fi + + local MEDIA_PATH="" + prompt_text "Path to media folder [$DEFAULT_MEDIA]:" "$DEFAULT_MEDIA" MEDIA_PATH + MEDIA_PATH="${MEDIA_PATH/#\~/$ACTUAL_HOME}"; MEDIA_PATH="${MEDIA_PATH%/}" + + mkdir -p "$JELLYFIN_DIR" + ensure_docker_dir_ownership "$JELLYFIN_DIR" + cd "$JELLYFIN_DIR" || return 1 + + local TZ_VAL; TZ_VAL=$(cat /etc/timezone 2>/dev/null || echo "UTC") + + # Hardware acceleration: only wire /dev/dri through if a render node exists, + # otherwise the container would fail to start on a GPU-less host. + local HWACCEL_BLOCK="" RENDER_GID + if [ -e /dev/dri/renderD128 ]; then + RENDER_GID=$(getent group render | cut -d: -f3 2>/dev/null || echo "989") + HWACCEL_BLOCK=" devices: + - /dev/dri/renderD128:/dev/dri/renderD128 + group_add: + - \"$RENDER_GID\"" + log_success "Render node found — enabling VAAPI hardware transcoding (render gid $RENDER_GID)" + else + log_warning "No /dev/dri/renderD128 — Jellyfin will use CPU transcoding." + fi + + cat > docker-compose.yml << JELLYFIN_COMPOSE +name: jellyfin + +services: + jellyfin: + image: jellyfin/jellyfin:latest + container_name: jellyfin + hostname: jellyfin + restart: unless-stopped + environment: + - TZ=$TZ_VAL +$HWACCEL_BLOCK + volumes: + - ./config:/config + - ./cache:/cache + - \${MEDIA_PATH}:/media:ro + ports: + - "8096:8096" + - "1900:1900/udp" + - "7359:7359/udp" +JELLYFIN_COMPOSE + + cat > .env << JELLYFIN_ENV +MEDIA_PATH=$MEDIA_PATH +JELLYFIN_ENV + + mkdir -p config cache + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JELLYFIN_DIR" + log_success "Jellyfin configured at $JELLYFIN_DIR" + + configure_caddy_for_service "Jellyfin" "8096" "jellyfin" + + write_readme "$JELLYFIN_DIR" << MD +# Jellyfin + +Free media server (movies, TV, music) — a no-paywall alternative to Emby. + +- Web UI: http://localhost:8096 +- Media folder (read-only): \`$MEDIA_PATH\` → mounted at /media +- App data: \`config/\` and \`cache/\` in this folder +- Edit the media path in \`.env\` (\`MEDIA_PATH=\`), then \`docker compose up -d\`. + +## Manage +\`\`\`bash +cd $JELLYFIN_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## Notes +- Hardware transcoding (Intel/AMD VAAPI) is enabled automatically when + \`/dev/dri/renderD128\` exists on the host; otherwise transcoding is CPU-only. +- First launch: open the web UI and complete the setup wizard, then add your + media libraries pointing at /media. +MD + + local START_JF="" + prompt_yn "Start Jellyfin now? (y/n):" "y" START_JF + if [ "$START_JF" = "y" ] || [ "$START_JF" = "Y" ]; then + docker compose up -d && log_success "Jellyfin started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Access at: http://localhost:8096" + echo "" +} diff --git a/services/lyrion.sh b/services/lyrion.sh new file mode 100644 index 0000000..ecdea8c --- /dev/null +++ b/services/lyrion.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# services/lyrion.sh — Lyrion Music Server for Squeezebox devices, apps, Chromecast. +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- LYRION MUSIC SERVER ----). +# Uses network_mode: host so UDP discovery (Chromecast, Squeezebox) works without +# manual port-forwarding. Own ~/docker/lyrion/ with compose + .env. + +register_service lyrion media "Music streaming server — Squeezebox, Chromecast (Lyrion)" 9000 + +install_lyrion() { + require_docker || return 1 + + local LYRION_DIR="$DOCKER_DIR/lyrion" + local DEFAULT_MUSIC="$ACTUAL_HOME/music" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Lyrion Music Server would:" + echo " - Create $LYRION_DIR with docker-compose.yml + .env (config/ playlists/)" + echo " - Mount a music folder (default $DEFAULT_MUSIC) read-only at /music" + echo " - Run with network_mode: host (required for Chromecast/Squeezebox UDP discovery)" + echo " - Expose port 9000 (web), 9090 (CLI), 3483 (players)" + echo " - Offer a Caddy reverse proxy and to start the container" + return 0 + fi + + local MUSIC_PATH="" + prompt_text "Path to music folder [$DEFAULT_MUSIC]:" "$DEFAULT_MUSIC" MUSIC_PATH + MUSIC_PATH="${MUSIC_PATH/#\~/$ACTUAL_HOME}"; MUSIC_PATH="${MUSIC_PATH%/}" + + mkdir -p "$LYRION_DIR" + ensure_docker_dir_ownership "$LYRION_DIR" + cd "$LYRION_DIR" || return 1 + + local TZ_VAL UID_VAL GID_VAL + TZ_VAL=$(cat /etc/timezone 2>/dev/null || echo "UTC") + UID_VAL=$(id -u "$ACTUAL_USER"); GID_VAL=$(id -g "$ACTUAL_USER") + + cat > docker-compose.yml << LYRION_COMPOSE +name: lyrion + +services: + lyrion: + image: lmscommunity/lyrionmusicserver:stable + container_name: lyrion + hostname: lyrion + restart: unless-stopped + network_mode: host + environment: + - HTTP_PORT=9000 + - PUID=$UID_VAL + - PGID=$GID_VAL + - TZ=$TZ_VAL + volumes: + - ./config:/config:rw + - \${MUSIC_PATH}:/music:ro + - ./playlists:/playlists:rw + - /etc/localtime:/etc/localtime:ro +LYRION_COMPOSE + + cat > .env << LYRION_ENV +MUSIC_PATH=$MUSIC_PATH +LYRION_ENV + + mkdir -p config playlists + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$LYRION_DIR" + log_success "Lyrion Music Server configured at $LYRION_DIR" + + configure_caddy_for_service "Lyrion" "9000" "lyrion" + + write_readme "$LYRION_DIR" << MD +# Lyrion Music Server + +Stream music to Squeezebox devices, the Squeezer Android/iOS app, and Chromecast. +Formerly known as Logitech Media Server (LMS). + +- Web UI: http://localhost:9000 +- Player port: 3483 (Squeezeboxes / apps) +- CLI port: 9090 +- Music folder (read-only): \`$MUSIC_PATH\` → mounted at /music +- App data: \`config/\` and \`playlists/\` + +## Manage +\`\`\`bash +cd $LYRION_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## Notes +- Uses \`network_mode: host\` so UDP discovery for Chromecast and Squeezebox devices + works without manual port mapping. +- Change the music path in \`.env\` (\`MUSIC_PATH=\`), then \`docker compose up -d\`. +- Add music libraries in the web UI under Settings → Music Library. +MD + + local START_LMS="" + prompt_yn "Start Lyrion Music Server now? (y/n):" "y" START_LMS + if [ "$START_LMS" = "y" ] || [ "$START_LMS" = "Y" ]; then + docker compose up -d && log_success "Lyrion started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Access at: http://localhost:9000" + echo " Note: uses host networking for Chromecast/Squeezebox UDP discovery" + echo "" +}