diff --git a/.env b/.env index 82ab841..0528c9b 100644 --- a/.env +++ b/.env @@ -172,6 +172,7 @@ PLAUSIBLE_HOSTNAME=plausible.${DOMAIN} PLEX_HOSTNAME=plex.${DOMAIN} PLEXIO_HOSTNAME=plexio.${DOMAIN} PORTAINER_HOSTNAME=portainer.${DOMAIN} +POSTER_CACHE_HOSTNAME=poster-cache.${DOMAIN} PROWLARR_HOSTNAME=prowlarr.${DOMAIN} QUETRE_HOSTNAME=quetre.${DOMAIN} RADARR_HOSTNAME=radarr.${DOMAIN} diff --git a/apps/aiometadata/.env b/apps/aiometadata/.env index 1a3a011..2c890bc 100644 --- a/apps/aiometadata/.env +++ b/apps/aiometadata/.env @@ -221,6 +221,25 @@ CATALOG_LIST_ITEMS_SIZE=20 # Optional: Override the logo URL in the manifest # ADDON_LOGO_URL=https://yourdomain.com/yourlogo.png + +# -- Poster Reverse Proxy Cache (Optional) -- +# Route poster images through the bundled `poster-cache` nginx service (defined in +# this app's compose.yaml). It caches posters on disk so repeated requests skip +# upstream latency and, with cache warming, serves them instantly. +# To enable: add the `poster-cache` profile to COMPOSE_PROFILES in the root .env, +# then uncomment the four variables below. +# +# Public HTTPS URL of the proxy, embedded in responses so Stremio fetches posters +# through it. Uses POSTER_CACHE_HOSTNAME from the root .env. +# POSTER_PROXY_PREFIX_URL=https://${POSTER_CACHE_HOSTNAME} +# Internal Docker URL for server-side poster warming (container-to-container). +# Optional — falls back to POSTER_PROXY_PREFIX_URL when unset. +# POSTER_WARMUP_URL=http://poster-cache:8888 +# Delay between poster warm batches, in milliseconds. (Default: 50) +# POSTER_WARMUP_DELAY_MS=50 +# Concurrent poster warm requests per batch. (Default: 1; 5 suits self-hosters) +# POSTER_WARMUP_CONCURRENCY=5 + # -- Database & Caching -- # Connection URI for the database. SQLite is used for local storage, but PostgreSQL is also supported. # For SQLite (default): diff --git a/apps/aiometadata/compose.yaml b/apps/aiometadata/compose.yaml index f5cc713..6a39233 100644 --- a/apps/aiometadata/compose.yaml +++ b/apps/aiometadata/compose.yaml @@ -44,4 +44,36 @@ services: retries: 5 profiles: - aiometadata + - all + + # Optional poster reverse-proxy cache (AIOMetadata guide §3). Opt-in: add the + # `poster-cache` profile to COMPOSE_PROFILES and uncomment the POSTER_* vars in + # apps/aiometadata/.env. Behind Authelia — /purge and /stats require 2FA, image + # fetches bypass (rules in apps/authelia/config/configuration.yml). + poster-cache: + image: nginx:alpine + container_name: poster-cache + restart: unless-stopped + volumes: + - ./poster-cache-nginx.conf:/etc/nginx/nginx.conf:ro + - ./poster-cache-stats.sh:/stats.sh:ro + - ./poster-cache-purge-handler.sh:/purge-handler.sh:ro + - ${DOCKER_DATA_DIR}/poster-cache:/var/cache/nginx + entrypoint: ["/bin/sh", "-c", "chown -R nginx:nginx /var/cache/nginx && nc -lk -p 9888 -e /purge-handler.sh & /stats.sh & exec nginx -g 'daemon off;'"] + expose: + - "8888" + labels: + - "traefik.enable=true" + - "traefik.http.routers.poster-cache.rule=Host(`${POSTER_CACHE_HOSTNAME?}`)" + - "traefik.http.routers.poster-cache.entrypoints=websecure" + - "traefik.http.routers.poster-cache.tls.certresolver=letsencrypt" + - "traefik.http.routers.poster-cache.middlewares=authelia@docker" + - "traefik.http.services.poster-cache.loadbalancer.server.port=8888" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8888/health"] + interval: 30s + timeout: 10s + retries: 3 + profiles: + - poster-cache - all \ No newline at end of file diff --git a/apps/aiometadata/poster-cache-nginx.conf b/apps/aiometadata/poster-cache-nginx.conf new file mode 100644 index 0000000..fc9519a --- /dev/null +++ b/apps/aiometadata/poster-cache-nginx.conf @@ -0,0 +1,87 @@ +user nginx; +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + # Cache storage on disk — adjust max_size to suit available space + proxy_cache_path /var/cache/nginx/posters + levels=1:2 + keys_zone=poster_cache:10m + max_size=10g + inactive=30d + use_temp_path=off; + + # Restore double-slash after scheme when a reverse proxy (e.g. Traefik) + # collapses "https://" to "https:/". + # Input: /https:/api.example.com/path -> https://api.example.com/path + # Input: /https://api.example.com/path -> https://api.example.com/path + map $request_uri $upstream_url { + ~^/(https?):/([^/].*)$ $1://$2; + ~^/(https?://.*)$ $1; + default ""; + } + + # Extract scheme + host from the upstream URL for resolving relative redirects + map $upstream_url $upstream_origin { + ~^(https?://[^/]+) $1; + default ""; + } + + log_format cache '$remote_addr - [$time_local] "$request" $status ' + '$body_bytes_sent $upstream_cache_status'; + access_log /var/log/nginx/access.log cache; + + server { + listen 8888; + + location = /health { + access_log off; + return 200 'ok'; + } + + location = /stats { + access_log off; + default_type application/json; + alias /tmp/cache-stats.json; + } + + location = /purge { + access_log off; + default_type application/json; + proxy_pass http://127.0.0.1:9888; + } + + location / { + resolver 127.0.0.11 valid=30s ipv6=off; + + if ($upstream_url = "") { + return 400; + } + + proxy_pass $upstream_url; + proxy_ssl_server_name on; + + # Rewrite relative upstream redirects into absolute URLs. + # Some upstreams (e.g. openposterdb) return relative 302 Location headers + # like "/c/abc/path" which the client would resolve against the proxy host. + # This rewrites them to point to the actual upstream origin. + # e.g. Location: /c/abc/path → Location: https://openposterdb.com/c/abc/path + proxy_redirect / $upstream_origin/; + + proxy_cache poster_cache; + proxy_cache_key $upstream_url; + proxy_cache_valid 200 30d; + proxy_ignore_headers Cache-Control Expires Vary; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + proxy_cache_lock on; + + add_header X-Cache-Status $upstream_cache_status; + + proxy_set_header Host $proxy_host; + proxy_set_header Accept-Encoding ""; + } + } +} diff --git a/apps/aiometadata/poster-cache-purge-handler.sh b/apps/aiometadata/poster-cache-purge-handler.sh new file mode 100755 index 0000000..d4eaf84 --- /dev/null +++ b/apps/aiometadata/poster-cache-purge-handler.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# HTTP handler for /purge — called by nc -lk -e +read -r method path _ +# Consume remaining headers +while read -r line; do + line=$(printf '%s' "$line" | tr -d '\r\n') + [ -z "$line" ] && break +done + +touch /tmp/purge-cache +BODY='{"success":true,"message":"cache purge scheduled"}' +printf "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s" ${#BODY} "$BODY" diff --git a/apps/aiometadata/poster-cache-stats.sh b/apps/aiometadata/poster-cache-stats.sh new file mode 100755 index 0000000..9bf1d0b --- /dev/null +++ b/apps/aiometadata/poster-cache-stats.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# Periodically writes cache stats to a JSON file served by nginx +CACHE_DIR="/var/cache/nginx/posters" +STATS_FILE="/tmp/cache-stats.json" +MAX_SIZE="${POSTER_CACHE_MAX_SIZE:-10g}" +INACTIVE="${POSTER_CACHE_INACTIVE:-30d}" + +while true; do + if [ -d "$CACHE_DIR" ]; then + size_bytes=$(du -sb "$CACHE_DIR" 2>/dev/null | cut -f1) + file_count=$(find "$CACHE_DIR" -type f 2>/dev/null | wc -l) + size_human=$(awk "BEGIN { + b = ${size_bytes:-0}; + if (b >= 1000000000) printf \"%.1fG\", b/1000000000; + else if (b >= 1000000) printf \"%.1fM\", b/1000000; + else if (b >= 1000) printf \"%.1fK\", b/1000; + else printf \"%dB\", b; + }") + else + size_bytes=0 + size_human="0B" + file_count=0 + fi + + # Check for purge flag + if [ -f /tmp/purge-cache ]; then + rm -f /tmp/purge-cache + rm -rf "$CACHE_DIR" + mkdir -p "$CACHE_DIR" + chown nginx:nginx "$CACHE_DIR" + size_bytes=0 + size_human="0B" + file_count=0 + fi + + cat > "$STATS_FILE" <