Skip to content

Commit a160579

Browse files
committed
Add Jellyfin tier 1 features: Intro Skipper, TMDb Box Sets, Jellystat
- Jellystat analytics container (postgres + app) behind jellyfin profile - Auto-install Intro Skipper and TMDb Box Sets plugins via configure.sh - Setup.sh generates Jellystat JWT secret and DB password - Health check and auto-healer cover Jellystat containers - Updated README with Jellyfin-specific features section - Updated SETUP.md with plugin and Jellystat setup steps
1 parent 8eaea02 commit a160579

8 files changed

Lines changed: 232 additions & 5 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ WIREGUARD_ADDRESSES=your_wireguard_address_here
3434
UN_SONARR_0_API_KEY=your_sonarr_api_key_here
3535
UN_RADARR_0_API_KEY=your_radarr_api_key_here
3636

37+
# --- JELLYSTAT (Jellyfin analytics, auto-generated by setup.sh) ---
38+
JELLYSTAT_JWT_SECRET=
39+
JELLYSTAT_DB_PASSWORD=
40+
3741
# --- OPTIONAL: MUSIC (Lidarr + Tidarr) ---
3842
# Uncomment if using music services (bash scripts/setup-music.sh)
3943
# UN_LIDARR_0_API_KEY=your_lidarr_api_key_here

README.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ New to self-hosted media? Start with the [basic version](https://github.com/liam
5252
| **Kometa** | Plex metadata automation (franchise collections, resolution overlays, RT ratings) |
5353
| **Unpackerr** | Auto-extracts RAR'd downloads for Radarr/Sonarr |
5454
| **Jellyfin** | Free, open-source media server (alternative to Plex, runs in Docker) |
55+
| **Intro Skipper** | Jellyfin plugin: auto-detects intros and adds a "Skip Intro" button on TV shows |
56+
| **TMDb Box Sets** | Jellyfin plugin: auto-creates franchise collections from TMDb data (Jellyfin's Kometa) |
57+
| **Jellystat** | Jellyfin analytics dashboard (like Tautulli for Plex: watch history, user stats, library insights) |
5558

5659
## Choosing Your Media Server
5760

@@ -61,11 +64,36 @@ New to self-hosted media? Start with the [basic version](https://github.com/liam
6164
| **Setup** | Install macOS app, runs natively | Runs in Docker, no app install |
6265
| **Remote access** | Built-in (Plex account) | Manual (reverse proxy) |
6366
| **Kometa support** | Yes (metadata automation) | No (use Jellyfin's built-in collections) |
64-
| **Franchise sort** | Yes (via franchise-sort.py) | No (use Jellyfin Collections plugin) |
67+
| **Franchise sort** | Yes (via franchise-sort.py) | No (TMDb Box Sets plugin auto-creates collections) |
68+
| **Analytics** | Tautulli (external) | Jellystat (included, Docker) |
69+
| **Intro skip** | Plex Pass feature | Intro Skipper plugin (free, auto-installed) |
6570
| **Client apps** | Plex apps on all platforms | Jellyfin apps + browser |
6671

6772
Default is **Plex**. To use Jellyfin, pass `--jellyfin` to the bootstrap command.
6873

74+
## Jellyfin-Specific Features
75+
76+
When running with `MEDIA_SERVER=jellyfin`, the stack includes these extras that close the gap with Plex Pass users:
77+
78+
| Feature | What It Does | Plex Equivalent |
79+
|---------|-------------|-----------------|
80+
| **Intro Skipper** | Detects TV show intros via audio fingerprinting, adds a "Skip Intro" button | Plex Pass intro skip |
81+
| **TMDb Box Sets** | Auto-creates franchise collections (Marvel, Star Wars, etc.) from TMDb data | Kometa collections |
82+
| **Jellystat** | Watch history, user activity, library stats dashboard at `http://localhost:3000` | Tautulli |
83+
84+
Intro Skipper and TMDb Box Sets are auto-installed as Jellyfin plugins by `configure.sh`. Jellystat runs as a separate Docker service behind the `jellyfin` profile.
85+
86+
### Jellystat Setup
87+
88+
After the stack is running:
89+
90+
1. Open `http://localhost:3000`
91+
2. Create an admin account
92+
3. Connect to your Jellyfin server: `http://jellyfin:8096`
93+
4. Enter your Jellyfin API key (Administration > API Keys)
94+
95+
Jellystat will start tracking watch history, user activity, and library statistics automatically.
96+
6997
## Optional: Music (Lidarr + Tidarr)
7098

7199
| Service | What It Does |
@@ -86,7 +114,7 @@ Then open Tidarr at `http://localhost:8484` to authenticate with your Tidal acco
86114

87115
| Script | Schedule | What It Does |
88116
|--------|----------|-------------|
89-
| Auto-healer | Hourly | Restarts VPN/containers if they go down (includes Jellyfin when active) |
117+
| Auto-healer | Hourly | Restarts VPN/containers if they go down (includes Jellyfin + Jellystat when active) |
90118
| Nightly backup | Daily | Backs up all configs and databases (14-day retention) |
91119
| Download watchdog | Every 15 min | Detects stalled/slow torrents, auto-fixes or swaps them |
92120
| Kometa | Every 4 hours | Updates Plex collections and metadata overlays |

SETUP.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,11 @@ It also writes credentials/API keys to `<MEDIA_DIR>/state/first-run-credentials.
198198
1. Open http://localhost:8096
199199
2. Complete the setup wizard
200200
3. Add libraries: Movies = `/data/movies`, TV Shows = `/data/tvshows`
201-
4. Generate an API key (Administration > API Keys) if you plan to use `archive-media.sh --only-watched`
201+
4. Generate an API key (Administration > API Keys) for Jellystat and `archive-media.sh --only-watched`
202+
203+
**Plugins:** `configure.sh` auto-installs **Intro Skipper** (adds "Skip Intro" button on TV shows) and **TMDb Box Sets** (auto-creates franchise collections). A Jellyfin restart is required after plugin install. If auto-install fails, install them manually via Administration > Plugins > Catalog.
204+
205+
**Jellystat** (analytics): Open `http://localhost:3000`, create an account, and connect to `http://jellyfin:8096` with your API key. Tracks watch history, user activity, and library stats.
202206

203207
---
204208

@@ -286,6 +290,7 @@ bash scripts/vpn-mode.sh status
286290
| Browse and request | http://localhost:5055 |
287291
| Watch (Plex) | http://localhost:32400/web |
288292
| Watch (Jellyfin) | http://localhost:8096 |
293+
| Jellyfin analytics (Jellystat) | http://localhost:3000 |
289294
| Check downloads | http://localhost:8080 |
290295
| Transcode status | http://localhost:8265 |
291296

docker-compose.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,43 @@ services:
247247
retries: 3
248248
restart: unless-stopped
249249

250+
jellystat-db:
251+
image: postgres:18.1
252+
container_name: jellystat-db
253+
profiles: ["jellyfin"]
254+
environment:
255+
- POSTGRES_DB=jfstat
256+
- POSTGRES_USER=postgres
257+
- POSTGRES_PASSWORD=${JELLYSTAT_DB_PASSWORD}
258+
volumes:
259+
- ${MEDIA_DIR}/config/jellystat/db:/var/lib/postgresql/data
260+
healthcheck:
261+
test: ["CMD-SHELL", "pg_isready -U postgres"]
262+
interval: 30s
263+
timeout: 10s
264+
retries: 3
265+
restart: unless-stopped
266+
267+
jellystat:
268+
image: cyfershepard/jellystat:latest
269+
container_name: jellystat
270+
profiles: ["jellyfin"]
271+
depends_on:
272+
jellystat-db:
273+
condition: service_healthy
274+
environment:
275+
- POSTGRES_USER=postgres
276+
- POSTGRES_PASSWORD=${JELLYSTAT_DB_PASSWORD}
277+
- POSTGRES_IP=jellystat-db
278+
- POSTGRES_PORT=5432
279+
- JWT_SECRET=${JELLYSTAT_JWT_SECRET}
280+
- TZ=${TIMEZONE:-America/New_York}
281+
volumes:
282+
- ${MEDIA_DIR}/config/jellystat/backup:/app/backend/backup-data
283+
ports:
284+
- 127.0.0.1:3000:3000
285+
restart: unless-stopped
286+
250287
# ─── Optional: Music (Lidarr + Tidarr) ───
251288
# Uncomment to add music management. Run: bash scripts/setup-music.sh first.
252289

scripts/auto-heal.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ source "$PROJECT_DIR/.env" 2>/dev/null || true
6161
# Check containers
6262
CONTAINERS="gluetun qbittorrent prowlarr sonarr radarr bazarr flaresolverr seerr tdarr unpackerr recyclarr lidarr tidarr"
6363
if [[ "${MEDIA_SERVER:-plex}" == "jellyfin" ]]; then
64-
CONTAINERS="$CONTAINERS jellyfin"
64+
CONTAINERS="$CONTAINERS jellyfin jellystat-db jellystat"
6565
fi
6666
for name in $CONTAINERS; do
6767
state=$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null)

scripts/configure.sh

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,99 @@ fi
407407

408408
echo ""
409409

410+
# 7. Jellyfin plugins (Intro Skipper + TMDb Box Sets)
411+
if [[ "$MEDIA_SERVER" == "jellyfin" ]]; then
412+
echo -e "${CYAN}[*] Installing Jellyfin plugins...${NC}"
413+
echo ""
414+
415+
# Wait for Jellyfin API
416+
JF_READY=false
417+
for i in $(seq 1 30); do
418+
jf_status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "http://localhost:8096/health" 2>/dev/null || true)
419+
if [[ "$jf_status" == "200" ]]; then
420+
JF_READY=true
421+
break
422+
fi
423+
sleep 2
424+
done
425+
426+
if [[ "$JF_READY" == true ]]; then
427+
# Extract API key from Jellyfin system.xml
428+
JF_XML="$MEDIA_DIR/config/jellyfin/config/system.xml"
429+
JF_API_KEY=""
430+
if [[ -f "$JF_XML" ]]; then
431+
JF_API_KEY=$(grep -o '<ApiKey>[^<]*</ApiKey>' "$JF_XML" 2>/dev/null | sed 's/<[^>]*>//g' || true)
432+
fi
433+
434+
if [[ -z "$JF_API_KEY" ]]; then
435+
warn "Could not extract Jellyfin API key from system.xml"
436+
warn "Generate one in Jellyfin > Administration > API Keys, then install plugins manually:"
437+
warn " - Intro Skipper: Administration > Plugins > Catalog > Intro Skipper > Install"
438+
warn " - TMDb Box Sets: Administration > Plugins > Catalog > TMDb Box Sets > Install"
439+
else
440+
# Fetch plugin catalog
441+
PLUGIN_CATALOG=$(curl -fsS "http://localhost:8096/Packages" -H "X-Emby-Token: $JF_API_KEY" 2>/dev/null || true)
442+
443+
install_jf_plugin() {
444+
local plugin_name="$1"
445+
if [[ -z "$PLUGIN_CATALOG" ]]; then
446+
warn "$plugin_name: could not fetch plugin catalog"
447+
return 0
448+
fi
449+
450+
# Extract the plugin GUID from catalog
451+
local plugin_guid
452+
plugin_guid=$(echo "$PLUGIN_CATALOG" | grep -o "\"name\":\"$plugin_name\"[^}]*\"guid\":\"[^\"]*\"" | grep -o '"guid":"[^"]*"' | cut -d'"' -f4 || true)
453+
454+
if [[ -z "$plugin_guid" ]]; then
455+
warn "$plugin_name: not found in plugin catalog"
456+
warn " Install manually: Administration > Plugins > Catalog > $plugin_name"
457+
return 0
458+
fi
459+
460+
# Get latest version
461+
local install_result
462+
install_result=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
463+
"http://localhost:8096/Packages/Installed/$plugin_name" \
464+
-H "X-Emby-Token: $JF_API_KEY" 2>/dev/null || echo "000")
465+
466+
if [[ "$install_result" =~ ^2 ]]; then
467+
log "$plugin_name plugin installed"
468+
else
469+
warn "$plugin_name: install returned HTTP $install_result"
470+
warn " Install manually: Administration > Plugins > Catalog > $plugin_name"
471+
fi
472+
}
473+
474+
install_jf_plugin "Intro Skipper"
475+
install_jf_plugin "TMDb Box Sets"
476+
477+
echo ""
478+
if [[ "$NON_INTERACTIVE" == true ]]; then
479+
warn "Restart Jellyfin to activate plugins: docker compose restart jellyfin"
480+
else
481+
echo -e " ${YELLOW}Plugins require a Jellyfin restart to activate.${NC}"
482+
read -p " Restart Jellyfin now? [Y/n] " -r restart_jf
483+
if [[ "$restart_jf" =~ ^[Nn]$ ]]; then
484+
warn "Skipped. Run 'docker compose restart jellyfin' when ready."
485+
else
486+
if docker compose restart jellyfin >/dev/null 2>&1; then
487+
log "Jellyfin restarted"
488+
else
489+
warn "Restart failed. Run 'docker compose restart jellyfin' manually."
490+
fi
491+
fi
492+
fi
493+
fi
494+
else
495+
warn "Jellyfin not ready after 60 seconds. Skipping plugin install."
496+
warn "Install manually after Jellyfin is running:"
497+
warn " - Intro Skipper: Administration > Plugins > Catalog > Intro Skipper > Install"
498+
warn " - TMDb Box Sets: Administration > Plugins > Catalog > TMDb Box Sets > Install"
499+
fi
500+
echo ""
501+
fi
502+
410503
# Print API keys for user to update config templates
411504
echo "=============================="
412505
echo -e " ${GREEN}Configuration complete!${NC}"

scripts/health-check.sh

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ echo ""
5151
echo "Containers:"
5252
CONTAINER_LIST="gluetun qbittorrent prowlarr sonarr radarr bazarr flaresolverr seerr tdarr unpackerr recyclarr kometa tautulli lidarr tidarr"
5353
if [[ "$MEDIA_SERVER" == "jellyfin" ]]; then
54-
CONTAINER_LIST="$CONTAINER_LIST jellyfin"
54+
CONTAINER_LIST="$CONTAINER_LIST jellyfin jellystat-db jellystat"
5555
fi
5656
for name in $CONTAINER_LIST; do
5757
state=$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null)
@@ -65,6 +65,8 @@ for name in $CONTAINER_LIST; do
6565
echo -e " ${YELLOW}SKIP${NC} $name (not installed)"
6666
elif [[ "$name" == "lidarr" || "$name" == "tidarr" ]] && [[ -z "$state" ]]; then
6767
echo -e " ${YELLOW}SKIP${NC} $name (music profile not enabled)"
68+
elif [[ "$name" == "jellystat-db" || "$name" == "jellystat" ]] && [[ -z "$state" ]]; then
69+
echo -e " ${YELLOW}SKIP${NC} $name (jellyfin profile not enabled)"
6870
else
6971
echo -e " ${RED}FAIL${NC} $name (${state:-not found})"
7072
((FAIL++))
@@ -124,6 +126,37 @@ echo ""
124126
if [[ "$MEDIA_SERVER" == "jellyfin" ]]; then
125127
echo "Jellyfin:"
126128
check_service "Jellyfin" "http://localhost:8096/health"
129+
130+
# Jellystat (only if containers exist)
131+
jellystat_state=$(docker inspect -f '{{.State.Status}}' jellystat 2>/dev/null || true)
132+
if [[ -n "$jellystat_state" ]]; then
133+
echo ""
134+
echo "Jellystat:"
135+
if [[ "$jellystat_state" == "running" ]]; then
136+
jellystat_http=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://localhost:3000" 2>/dev/null)
137+
if [[ "$jellystat_http" =~ ^[23][0-9][0-9]$ ]]; then
138+
echo -e " ${GREEN}OK${NC} Jellystat"
139+
((PASS++))
140+
else
141+
echo -e " ${RED}FAIL${NC} Jellystat (HTTP $jellystat_http)"
142+
((FAIL++))
143+
fi
144+
else
145+
echo -e " ${RED}FAIL${NC} Jellystat ($jellystat_state)"
146+
((FAIL++))
147+
fi
148+
149+
jellystat_db_health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}unknown{{end}}' jellystat-db 2>/dev/null || true)
150+
if [[ "$jellystat_db_health" == "healthy" ]]; then
151+
echo -e " ${GREEN}OK${NC} Jellystat DB"
152+
((PASS++))
153+
elif [[ -z "$jellystat_db_health" ]]; then
154+
echo -e " ${YELLOW}SKIP${NC} Jellystat DB (not found)"
155+
else
156+
echo -e " ${RED}FAIL${NC} Jellystat DB (health: $jellystat_db_health)"
157+
((FAIL++))
158+
fi
159+
fi
127160
else
128161
echo "Plex:"
129162
plex_status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://localhost:32400/web" 2>/dev/null)

scripts/setup.sh

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,44 @@ echo "Creating folders..."
6969
mkdir -p "$MEDIA_DIR"/{config,Downloads,Movies,"TV Shows",logs,state,backups,tdarr-transcode-cache}
7070
mkdir -p "$MEDIA_DIR"/config/{qbittorrent,prowlarr,sonarr,radarr,bazarr,seerr,kometa,recyclarr}
7171
mkdir -p "$MEDIA_DIR"/config/tdarr/{server,configs,logs}
72+
mkdir -p "$MEDIA_DIR"/config/jellystat/{db,backup}
7273
echo -e " ${GREEN}Done${NC}"
7374
echo ""
7475

7576
# Create .env from example
77+
# Generate Jellystat secrets if not already set
78+
if [[ -f "$SCRIPT_DIR/.env" ]]; then
79+
source "$SCRIPT_DIR/.env"
80+
fi
81+
if [[ -z "${JELLYSTAT_JWT_SECRET:-}" ]]; then
82+
JELLYSTAT_JWT_SECRET=$(openssl rand -hex 32)
83+
echo " Generated JELLYSTAT_JWT_SECRET"
84+
fi
85+
if [[ -z "${JELLYSTAT_DB_PASSWORD:-}" ]]; then
86+
JELLYSTAT_DB_PASSWORD=$(openssl rand -hex 16)
87+
echo " Generated JELLYSTAT_DB_PASSWORD"
88+
fi
89+
7690
if [[ -f "$SCRIPT_DIR/.env" ]]; then
7791
echo -e "${YELLOW}Note:${NC} .env already exists. Skipping creation."
92+
# Write Jellystat secrets if missing from existing .env
93+
if ! grep -q "^JELLYSTAT_JWT_SECRET=" "$SCRIPT_DIR/.env" 2>/dev/null; then
94+
printf '\nJELLYSTAT_JWT_SECRET=%s\n' "$JELLYSTAT_JWT_SECRET" >> "$SCRIPT_DIR/.env"
95+
elif grep -q "^JELLYSTAT_JWT_SECRET=$" "$SCRIPT_DIR/.env" 2>/dev/null; then
96+
sed -i '' "s|^JELLYSTAT_JWT_SECRET=$|JELLYSTAT_JWT_SECRET=$JELLYSTAT_JWT_SECRET|" "$SCRIPT_DIR/.env"
97+
fi
98+
if ! grep -q "^JELLYSTAT_DB_PASSWORD=" "$SCRIPT_DIR/.env" 2>/dev/null; then
99+
printf 'JELLYSTAT_DB_PASSWORD=%s\n' "$JELLYSTAT_DB_PASSWORD" >> "$SCRIPT_DIR/.env"
100+
elif grep -q "^JELLYSTAT_DB_PASSWORD=$" "$SCRIPT_DIR/.env" 2>/dev/null; then
101+
sed -i '' "s|^JELLYSTAT_DB_PASSWORD=$|JELLYSTAT_DB_PASSWORD=$JELLYSTAT_DB_PASSWORD|" "$SCRIPT_DIR/.env"
102+
fi
78103
else
79104
echo "Creating .env file..."
80105
sed "s|/Users/YOURUSERNAME/Media|$MEDIA_DIR|g" "$SCRIPT_DIR/.env.example" \
81106
| sed "s|PUID=501|PUID=$USER_PUID|g" \
82107
| sed "s|PGID=20|PGID=$USER_PGID|g" \
108+
| sed "s|^JELLYSTAT_JWT_SECRET=$|JELLYSTAT_JWT_SECRET=$JELLYSTAT_JWT_SECRET|g" \
109+
| sed "s|^JELLYSTAT_DB_PASSWORD=$|JELLYSTAT_DB_PASSWORD=$JELLYSTAT_DB_PASSWORD|g" \
83110
> "$SCRIPT_DIR/.env"
84111
chmod 600 "$SCRIPT_DIR/.env"
85112
echo -e " ${GREEN}Done${NC}"

0 commit comments

Comments
 (0)