Sposta storage video su disco dedicato e completa ops/SMTP in produzione.

Recordings e Garage usano bind mount su /media/videos; aggiunge ntfy self-hosted,
notifiche critical+warning, SMTP Aruba SSL/465 e fix cron replay.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 23:13:08 +02:00
parent 52cfffb83f
commit da75582dae
14 changed files with 278 additions and 44 deletions

View File

@@ -13,7 +13,7 @@ module Recordings
return @recording unless @recording.expires_at <= WINDOW_DAYS.days.from_now return @recording unless @recording.expires_at <= WINDOW_DAYS.days.from_now
recipients.each do |user| recipients.each do |user|
Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user).deliver_now deliver_expiring_soon(user)
end end
@recording.update!(expiry_warning_sent_at: Time.current) @recording.update!(expiry_warning_sent_at: Time.current)
@@ -22,6 +22,12 @@ module Recordings
private private
def deliver_expiring_soon(user)
Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user).deliver_now
rescue EOFError => e
Rails.logger.warn("[Recordings::NotifyExpiring] SMTP EOF on close for #{user.email}: #{e.message}")
end
def recipients def recipients
club = @recording.team.club club = @recording.team.club
owner = club.owner owner = club.owner

View File

@@ -9,7 +9,7 @@ module Recordings
return @recording unless @recording.ready? return @recording unless @recording.ready?
recipients.each do |user| recipients.each do |user|
Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user).deliver_now deliver_replay_ready(user)
end end
@recording.update!(ready_notified_at: Time.current) @recording.update!(ready_notified_at: Time.current)
@@ -18,6 +18,13 @@ module Recordings
private private
def deliver_replay_ready(user)
Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user).deliver_now
rescue EOFError => e
# Aruba SMTP (465/SSL) chiude la socket prima del QUIT: la mail è già partita.
Rails.logger.warn("[Recordings::NotifyReady] SMTP EOF on close for #{user.email}: #{e.message}")
end
def recipients def recipients
club = @recording.team.club club = @recording.team.club
owner = club.owner owner = club.owner

View File

@@ -54,13 +54,18 @@ Rails.application.configure do
if ENV["SMTP_ADDRESS"].present? if ENV["SMTP_ADDRESS"].present?
config.action_mailer.delivery_method = :smtp config.action_mailer.delivery_method = :smtp
smtp_port = ENV.fetch("SMTP_PORT", 587).to_i
smtp_ssl = ENV.fetch("SMTP_SSL", smtp_port == 465 ? "true" : "false") == "true"
config.action_mailer.smtp_settings = { config.action_mailer.smtp_settings = {
address: ENV["SMTP_ADDRESS"], address: ENV["SMTP_ADDRESS"],
port: ENV.fetch("SMTP_PORT", 587).to_i, port: smtp_port,
user_name: ENV["SMTP_USERNAME"], user_name: ENV["SMTP_USERNAME"],
password: ENV["SMTP_PASSWORD"], password: ENV["SMTP_PASSWORD"],
authentication: ENV.fetch("SMTP_AUTH", "plain"), authentication: ENV.fetch("SMTP_AUTH", "plain"),
enable_starttls_auto: ENV.fetch("SMTP_STARTTLS", "true") == "true" ssl: smtp_ssl,
enable_starttls_auto: !smtp_ssl && ENV.fetch("SMTP_STARTTLS", "true") == "true",
open_timeout: ENV.fetch("SMTP_OPEN_TIMEOUT", "15").to_i,
read_timeout: ENV.fetch("SMTP_READ_TIMEOUT", "60").to_i
} }
end end
config.i18n.fallbacks = true config.i18n.fallbacks = true

View File

@@ -90,7 +90,7 @@ module MatchLiveTv
end end
def mail_from def mail_from
ENV.fetch("MAILER_FROM", "Match Live TV <noreply@matchlive.it>") ENV.fetch("MAILER_FROM", "Match Live TV <noreply@matchlivetv.it>")
end end
def password_reset_expiry_hours def password_reset_expiry_hours

View File

@@ -14,25 +14,63 @@ Sistema integrato per health check continui, analisi log, dashboard incidenti e
| `/admin/ops` | Dashboard incidenti | | `/admin/ops` | Dashboard incidenti |
| `GET /up/deep` | Health check esteso (JSON) | | `GET /up/deep` | Health check esteso (JSON) |
## Setup notifiche ntfy (telefono) ## Setup notifiche ntfy self-hosted (telefono)
1. Installa l'app [ntfy](https://ntfy.sh) su Android/iOS Le push **ops** (disco pieno, Sidekiq, servizi giù) non passano da Firebase: Rails invia un POST al server **ntfy** incluso in `docker-compose.prod.yml`.
2. Scegli un topic segreto (es. `matchlivetv-ops-eminux-a8f3b2`)
3. Iscriviti al topic nell'app ### 1. Server (Docker)
4. Sul server, in `infra/.env`:
```bash ```bash
OPS_NTFY_URL=https://ntfy.sh/matchlivetv-ops-eminux-a8f3b2 cd /opt/matchlivetv/infra
OPS_NOTIFY_SEVERITIES=critical docker compose -f docker-compose.prod.yml --env-file .env up -d ntfy
```
Servizio in ascolto su **LAN** `http://192.168.1.146:8090` (non esporre 8090 su Internet).
### 2. HTTPS (Nginx Proxy Manager)
Crea un **Proxy Host** (consigliato sottodominio dedicato):
| Campo | Valore |
|-------|--------|
| Domain | `ntfy.matchlivetv.it` |
| Forward | `http://192.168.1.146:8090` |
| WebSockets | **ON** |
| SSL | Let's Encrypt |
In `infra/.env`:
```bash
NTFY_PUBLIC_URL=https://ntfy.matchlivetv.it
OPS_NTFY_TOPIC=matchlivetv-ops-eminux-a8f3b2 # topic segreto, a piacere
OPS_NTFY_URL=http://ntfy:80/matchlivetv-ops-eminux-a8f3b2
OPS_NOTIFY_SEVERITIES=critical,warning
OPS_NOTIFY_COOLDOWN_MINUTES=30 OPS_NOTIFY_COOLDOWN_MINUTES=30
``` ```
5. Test: `OPS_NTFY_URL` usa il nome Docker `ntfy` (rete interna). Il telefono usa invece `NTFY_PUBLIC_URL`.
### 3. App sul telefono
1. Installa [ntfy](https://ntfy.sh) (Android/iOS)
2. **Impostazioni → Default server** (o «Usa altro server») → `https://ntfy.matchlivetv.it`
In casa, in alternativa: `http://192.168.1.146:8090`
3. **+** → iscriviti al topic `OPS_NTFY_TOPIC` (es. `matchlivetv-ops-eminux-a8f3b2`)
4. Abilita notifiche per quel topic
### 4. Test
```bash ```bash
docker compose -f docker-compose.prod.yml exec -T rails bundle exec rails ops:test_notify docker compose -f docker-compose.prod.yml exec -T rails bundle exec rails ops:test_notify
``` ```
Dovresti ricevere una push «Test notifica ops» sul telefono.
### Sicurezza
- Il topic è la «password»: usa un nome lungo e casuale, non pubblicarlo
- Opzionale: abilita auth su ntfy (`OPS_NTFY_TOKEN` + utente in `user.db`) per topic protetti
## Variabili ambiente ## Variabili ambiente
Vedi [`infra/.env.production.example`](../infra/.env.production.example). Vedi [`infra/.env.production.example`](../infra/.env.production.example).

View File

@@ -12,7 +12,7 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI) PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
PRIVACY_CONTACT_EMAIL=privacy@matchlive.it PRIVACY_CONTACT_EMAIL=privacy@matchlive.it
MAILER_FROM=Match Live TV <noreply@matchlive.it> MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
PASSWORD_RESET_EXPIRY_HOURS=2 PASSWORD_RESET_EXPIRY_HOURS=2
# Google Analytics 4 (opzionale — caricato solo dopo consenso cookie «Accetta tutti») # Google Analytics 4 (opzionale — caricato solo dopo consenso cookie «Accetta tutti»)

View File

@@ -42,13 +42,15 @@ PRIVACY_CONTACT_EMAIL=privacy@matchlive.it
PRIVACY_CONTROLLER_VAT= PRIVACY_CONTROLLER_VAT=
# Email transazionali (reset password, inviti) # Email transazionali (reset password, inviti)
MAILER_FROM=Match Live TV <noreply@matchlive.it> MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
SMTP_ADDRESS= # Aruba: smtps.aruba.it porta 465 con SSL (non STARTTLS su 587)
SMTP_PORT=587 SMTP_ADDRESS=smtps.aruba.it
SMTP_USERNAME= SMTP_PORT=465
SMTP_USERNAME=noreply@matchlivetv.it
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_AUTH=plain SMTP_AUTH=plain
SMTP_STARTTLS=true SMTP_SSL=true
SMTP_STARTTLS=false
PASSWORD_RESET_EXPIRY_HOURS=2 PASSWORD_RESET_EXPIRY_HOURS=2
# Disco video dedicato (recordings MediaMTX + dati Garage). Montare es. 800GB su /media/videos # Disco video dedicato (recordings MediaMTX + dati Garage). Montare es. 800GB su /media/videos
@@ -63,12 +65,16 @@ REPLAY_STORAGE_ACCESS_KEY_ID=
REPLAY_STORAGE_SECRET_ACCESS_KEY= REPLAY_STORAGE_SECRET_ACCESS_KEY=
REPLAY_STORAGE_FORCE_PATH_STYLE=true REPLAY_STORAGE_FORCE_PATH_STYLE=true
# Ops monitoring — notifiche push (ntfy) e health check # Ops monitoring — notifiche push (ntfy self-hosted) e health check
# App ntfy: https://ntfy.sh — iscriviti al topic e imposta OPS_NTFY_URL # 1) Avvia servizio: docker compose up -d ntfy
OPS_NTFY_URL=https://ntfy.sh/matchlivetv-ops-YOUR_SECRET_TOPIC # 2) NPM: proxy HTTPS → http://192.168.1.146:8090 (es. ntfy.matchlivetv.it, WebSockets ON)
# 3) App ntfy sul telefono: server custom = NTFY_PUBLIC_URL, iscriviti a OPS_NTFY_TOPIC
NTFY_PUBLIC_URL=https://ntfy.matchlivetv.it
# Topic segreto: stesso nome in app ntfy (subscribe) e in OPS_NTFY_URL
OPS_NTFY_URL=http://ntfy:80/matchlivetv-ops-YOUR_SECRET_SUFFIX
OPS_NTFY_TOKEN= OPS_NTFY_TOKEN=
OPS_ALERT_EMAIL= OPS_ALERT_EMAIL=
OPS_NOTIFY_SEVERITIES=critical OPS_NOTIFY_SEVERITIES=critical,warning
OPS_NOTIFY_COOLDOWN_MINUTES=30 OPS_NOTIFY_COOLDOWN_MINUTES=30
OPS_HEALTH_INTERVAL_SECS=180 OPS_HEALTH_INTERVAL_SECS=180
OPS_HEALTH_TOKEN= OPS_HEALTH_TOKEN=

View File

@@ -37,7 +37,7 @@ services:
volumes: volumes:
- ./mediamtx.yml:/mediamtx.yml:ro - ./mediamtx.yml:/mediamtx.yml:ro
- ./slates:/slates:ro - ./slates:/slates:ro
- recordings:/recordings - ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings
command: /mediamtx.yml command: /mediamtx.yml
# Immagine distroless: niente shell/wget/curl; API :9997 non esposta sull'host # Immagine distroless: niente shell/wget/curl; API :9997 non esposta sull'host
healthcheck: healthcheck:
@@ -77,13 +77,14 @@ services:
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro} PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)} PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-} PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-}
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlive.it>} MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlivetv.it>}
SMTP_ADDRESS: ${SMTP_ADDRESS:-} SMTP_ADDRESS: ${SMTP_ADDRESS:-}
SMTP_PORT: ${SMTP_PORT:-587} SMTP_PORT: ${SMTP_PORT:-465}
SMTP_USERNAME: ${SMTP_USERNAME:-} SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_AUTH: ${SMTP_AUTH:-plain} SMTP_AUTH: ${SMTP_AUTH:-plain}
SMTP_STARTTLS: ${SMTP_STARTTLS:-true} SMTP_SSL: ${SMTP_SSL:-true}
SMTP_STARTTLS: ${SMTP_STARTTLS:-false}
RAILS_LOG_TO_STDOUT: "true" RAILS_LOG_TO_STDOUT: "true"
RAILS_LOG_LEVEL: ${RAILS_LOG_LEVEL:-info} RAILS_LOG_LEVEL: ${RAILS_LOG_LEVEL:-info}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
@@ -102,6 +103,20 @@ services:
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-} REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true} REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
GOOGLE_ANALYTICS_MEASUREMENT_ID: ${GOOGLE_ANALYTICS_MEASUREMENT_ID:-} GOOGLE_ANALYTICS_MEASUREMENT_ID: ${GOOGLE_ANALYTICS_MEASUREMENT_ID:-}
OPS_NTFY_URL: ${OPS_NTFY_URL:-}
OPS_NTFY_TOKEN: ${OPS_NTFY_TOKEN:-}
OPS_ALERT_EMAIL: ${OPS_ALERT_EMAIL:-}
OPS_NOTIFY_SEVERITIES: ${OPS_NOTIFY_SEVERITIES:-critical,warning}
OPS_NOTIFY_COOLDOWN_MINUTES: ${OPS_NOTIFY_COOLDOWN_MINUTES:-30}
OPS_HEALTH_INTERVAL_SECS: ${OPS_HEALTH_INTERVAL_SECS:-180}
OPS_HEALTH_TOKEN: ${OPS_HEALTH_TOKEN:-}
OPS_DISK_WARN_PERCENT: ${OPS_DISK_WARN_PERCENT:-80}
OPS_DISK_CRIT_PERCENT: ${OPS_DISK_CRIT_PERCENT:-90}
OPS_RECORDINGS_WARN_GB: ${OPS_RECORDINGS_WARN_GB:-20}
OPS_RECORDINGS_CRIT_GB: ${OPS_RECORDINGS_CRIT_GB:-50}
OPS_SIDEKIQ_STALE_SECS: ${OPS_SIDEKIQ_STALE_SECS:-300}
OPS_LOG_SUBSCRIBER: ${OPS_LOG_SUBSCRIBER:-false}
SENTRY_DSN: ${SENTRY_DSN:-}
ports: ports:
- "3000:3000" # Solo LAN — NPM proxy HTTPS - "3000:3000" # Solo LAN — NPM proxy HTTPS
depends_on: depends_on:
@@ -114,7 +129,7 @@ services:
garage: garage:
condition: service_started condition: service_started
volumes: volumes:
- recordings:/recordings - ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings
- rails_storage:/rails/storage - rails_storage:/rails/storage
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"] test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"]
@@ -148,13 +163,14 @@ services:
MEDIAMTX_INTERNAL_RTMP_URL: rtmp://mediamtx:1935 MEDIAMTX_INTERNAL_RTMP_URL: rtmp://mediamtx:1935
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888} HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000} APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlive.it>} MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlivetv.it>}
SMTP_ADDRESS: ${SMTP_ADDRESS:-} SMTP_ADDRESS: ${SMTP_ADDRESS:-}
SMTP_PORT: ${SMTP_PORT:-587} SMTP_PORT: ${SMTP_PORT:-465}
SMTP_USERNAME: ${SMTP_USERNAME:-} SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_AUTH: ${SMTP_AUTH:-plain} SMTP_AUTH: ${SMTP_AUTH:-plain}
SMTP_STARTTLS: ${SMTP_STARTTLS:-true} SMTP_SSL: ${SMTP_SSL:-true}
SMTP_STARTTLS: ${SMTP_STARTTLS:-false}
RAILS_LOG_LEVEL: ${RAILS_LOG_LEVEL:-info} RAILS_LOG_LEVEL: ${RAILS_LOG_LEVEL:-info}
RECORDINGS_PATH: /recordings RECORDINGS_PATH: /recordings
REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-} REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-}
@@ -163,13 +179,29 @@ services:
REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-} REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-}
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-} REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true} REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
OPS_NTFY_URL: ${OPS_NTFY_URL:-}
OPS_NTFY_TOKEN: ${OPS_NTFY_TOKEN:-}
OPS_ALERT_EMAIL: ${OPS_ALERT_EMAIL:-}
OPS_NOTIFY_SEVERITIES: ${OPS_NOTIFY_SEVERITIES:-critical,warning}
OPS_NOTIFY_COOLDOWN_MINUTES: ${OPS_NOTIFY_COOLDOWN_MINUTES:-30}
OPS_HEALTH_INTERVAL_SECS: ${OPS_HEALTH_INTERVAL_SECS:-180}
OPS_HEALTH_TOKEN: ${OPS_HEALTH_TOKEN:-}
OPS_DISK_WARN_PERCENT: ${OPS_DISK_WARN_PERCENT:-80}
OPS_DISK_CRIT_PERCENT: ${OPS_DISK_CRIT_PERCENT:-90}
OPS_RECORDINGS_WARN_GB: ${OPS_RECORDINGS_WARN_GB:-20}
OPS_RECORDINGS_CRIT_GB: ${OPS_RECORDINGS_CRIT_GB:-50}
OPS_SIDEKIQ_STALE_SECS: ${OPS_SIDEKIQ_STALE_SECS:-300}
OPS_LOG_SUBSCRIBER: ${OPS_LOG_SUBSCRIBER:-false}
SENTRY_DSN: ${SENTRY_DSN:-}
depends_on: depends_on:
rails: rails:
condition: service_healthy condition: service_healthy
garage: garage:
condition: service_started condition: service_started
ntfy:
condition: service_started
volumes: volumes:
- recordings:/recordings - ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings
- rails_storage:/rails/storage - rails_storage:/rails/storage
garage: garage:
@@ -177,15 +209,31 @@ services:
restart: unless-stopped restart: unless-stopped
# API S3 solo rete Docker (rails/sidekiq → http://garage:3900). Non pubblicare 3900 su WAN. # API S3 solo rete Docker (rails/sidekiq → http://garage:3900). Non pubblicare 3900 su WAN.
volumes: volumes:
- garage_meta:/var/lib/garage/meta - ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/garage/meta:/var/lib/garage/meta
- garage_data:/var/lib/garage/data - ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/garage/data:/var/lib/garage/data
- ./garage/garage.prod.toml:/etc/garage.toml:ro - ./garage/garage.prod.toml:/etc/garage.toml:ro
command: ["/garage", "-c", "/etc/garage.toml", "server"] command: ["/garage", "-c", "/etc/garage.toml", "server"]
ntfy:
image: binwiederhier/ntfy:latest
restart: unless-stopped
# Solo LAN :8090 — esporre su HTTPS via NPM (es. ntfy.matchlivetv.it). Non aprire 8090 su WAN.
command: serve
environment:
NTFY_BASE_URL: ${NTFY_PUBLIC_URL:-http://127.0.0.1:8090}
volumes:
- ntfy_data:/var/lib/ntfy
- ./ntfy/server.yml:/etc/ntfy/server.yml:ro
ports:
- "8090:80"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80/v1/health"]
interval: 30s
timeout: 5s
retries: 3
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
recordings:
rails_storage: rails_storage:
garage_meta: ntfy_data:
garage_data:

9
infra/ntfy/server.yml Normal file
View File

@@ -0,0 +1,9 @@
# ntfy self-hosted — Match Live TV (ops alerts)
# base-url impostato via NTFY_BASE_URL in docker-compose (NTFY_PUBLIC_URL in .env)
# Docs: https://docs.ntfy.sh/config/
behind-proxy: true
cache-file: "/var/lib/ntfy/cache.db"
attachment-cache-dir: "/var/lib/ntfy/attachments"
message-size-limit: "4k"
visitor-subscription-limit: 10

View File

@@ -6,6 +6,12 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DEST="${ROOT}/garage/garage.prod.toml" DEST="${ROOT}/garage/garage.prod.toml"
TEMPLATE="${ROOT}/garage/garage.prod.toml.example" TEMPLATE="${ROOT}/garage/garage.prod.toml.example"
if [ -d "$DEST" ]; then
echo "ERRORE: $DEST è una directory (bind mount Docker senza file sorgente)."
echo "Rimuovi la directory e rilancia questo script."
exit 1
fi
if [ -f "$DEST" ]; then if [ -f "$DEST" ]; then
echo "Config Garage produzione già presente: $DEST" echo "Config Garage produzione già presente: $DEST"
exit 0 exit 0

View File

@@ -8,9 +8,14 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}" COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
ENV_FILE="${ENV_FILE:-${ROOT}/.env}" ENV_FILE="${ENV_FILE:-${ROOT}/.env}"
GARAGE_CONTAINER="${GARAGE_CONTAINER:-}" GARAGE_CONTAINER="${GARAGE_CONTAINER:-}"
VIDEOS_ROOT="${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}"
if [ -z "$CAPACITY" ]; then if [ -z "$CAPACITY" ]; then
avail="$(df -BG "${ROOT}" 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G' || echo 0)" capacity_df="${VIDEOS_ROOT}"
if [ ! -d "$capacity_df" ]; then
capacity_df="${ROOT}"
fi
avail="$(df -BG "${capacity_df}" 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G' || echo 0)"
if [ "$avail" -lt 1 ]; then if [ "$avail" -lt 1 ]; then
avail="$(df -BG / 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G')" avail="$(df -BG / 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G')"
fi fi

View File

@@ -14,14 +14,14 @@ mkdir -p "$LOG_DIR"
MARKER="# matchlivetv-replay-cron" MARKER="# matchlivetv-replay-cron"
CRON_BLOCK="${MARKER} CRON_BLOCK="${MARKER}
30 * * * * ${RUNNER} recordings:cleanup_local >> ${LOG_FILE} 2>&1 30 * * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:cleanup_local >> ${LOG_FILE} 2>&1
0 3 * * * ${RUNNER} recordings:purge_expired >> ${LOG_FILE} 2>&1 0 3 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:purge_expired >> ${LOG_FILE} 2>&1
0 8 * * * ${RUNNER} recordings:expiry_warnings >> ${LOG_FILE} 2>&1 0 8 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:expiry_warnings >> ${LOG_FILE} 2>&1
*/10 * * * * ${SCAN_LOGS} >> ${OPS_LOG_FILE} 2>&1 */10 * * * * mkdir -p ${LOG_DIR} && ${SCAN_LOGS} >> ${OPS_LOG_FILE} 2>&1
" "
existing="$(crontab -l 2>/dev/null || true)" existing="$(crontab -l 2>/dev/null || true)"
filtered="$(echo "$existing" | grep -v "$MARKER" | grep -v 'run_rails_task\.sh' || true)" filtered="$(echo "$existing" | grep -v "$MARKER" | grep -v 'run_rails_task\.sh' | grep -v 'scan_ops_logs\.sh' || true)"
if echo "$existing" | grep -q "$MARKER"; then if echo "$existing" | grep -q "$MARKER"; then
echo "Crontab replay già presente, aggiorno..." echo "Crontab replay già presente, aggiorno..."
fi fi

View File

@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Migra recordings + Garage da volumi Docker al disco video (es. /media/videos/matchlivetv).
# Uso: sudo bash scripts/migrate_videos_storage.sh
# Richiede: docker compose prod fermo o in fase di stop; disco montato su MATCHLIVETV_VIDEOS_ROOT.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
VIDEOS_ROOT="${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
ENV_FILE="${ENV_FILE:-${ROOT}/.env}"
PROJECT="${COMPOSE_PROJECT_NAME:-infra}"
VIDEOS_MOUNT="$(dirname "$VIDEOS_ROOT")"
if ! mountpoint -q "$VIDEOS_MOUNT" 2>/dev/null && [ ! -d "$VIDEOS_ROOT" ]; then
echo "ERRORE: disco video non montato (atteso ${VIDEOS_MOUNT})"
exit 1
fi
echo "Destinazione: ${VIDEOS_ROOT}"
df -h "$VIDEOS_ROOT" 2>/dev/null || df -h "$VIDEOS_MOUNT"
fs_mkdir() {
if mkdir -p "$@" 2>/dev/null; then
return 0
fi
sudo mkdir -p "$@"
}
fs_rsync() {
if rsync -aH "$@" 2>/dev/null; then
return 0
fi
sudo rsync -aH "$@"
}
fs_mkdir "${VIDEOS_ROOT}/recordings" "${VIDEOS_ROOT}/garage/meta" "${VIDEOS_ROOT}/garage/data"
if [ ! -w "$VIDEOS_ROOT" ]; then
sudo chown -R "$(id -un)":"$(id -gn)" "$VIDEOS_ROOT"
fi
volume_path() {
local mp
mp="$(docker volume inspect "${PROJECT}_$1" --format '{{ .Mountpoint }}' 2>/dev/null || true)"
[ -n "$mp" ] && [ -d "$mp" ] && echo "$mp" && return 0
mp="$(sudo docker volume inspect "${PROJECT}_$1" --format '{{ .Mountpoint }}' 2>/dev/null || true)"
[ -n "$mp" ] && echo "$mp"
}
copy_volume() {
local vol="$1" dest="$2"
local src
src="$(volume_path "$vol")"
if [ -z "$src" ] || [ ! -d "$src" ]; then
echo "Volume ${PROJECT}_${vol} assente, salto copia verso ${dest}"
return 0
fi
if [ -n "$(sudo ls -A "$dest" 2>/dev/null || true)" ]; then
echo "Già presente in ${dest}, salto copia da ${src}"
return 0
fi
echo "Copia ${src}${dest}..."
fs_rsync "${src}/" "${dest}/"
}
echo "Arresto servizi che usano recordings/Garage..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" stop mediamtx sidekiq rails garage
copy_volume recordings "${VIDEOS_ROOT}/recordings"
copy_volume garage_meta "${VIDEOS_ROOT}/garage/meta"
copy_volume garage_data "${VIDEOS_ROOT}/garage/data"
if ! grep -q '^MATCHLIVETV_VIDEOS_ROOT=' "$ENV_FILE" 2>/dev/null; then
echo "MATCHLIVETV_VIDEOS_ROOT=${VIDEOS_ROOT}" >> "$ENV_FILE"
echo "Aggiunto MATCHLIVETV_VIDEOS_ROOT a ${ENV_FILE}"
fi
if [ -d "${ROOT}/garage/garage.prod.toml" ]; then
echo "ERRORE: ${ROOT}/garage/garage.prod.toml è una directory (bind mount Docker corrotto)."
echo "Rimuovi la directory e ripristina il file TOML prima di continuare."
exit 1
fi
echo "Riavvio stack..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --force-recreate mediamtx garage rails sidekiq
echo "Ridimensiono capacità Garage sul disco video..."
export MATCHLIVETV_VIDEOS_ROOT="$VIDEOS_ROOT"
bash "${ROOT}/scripts/garage_set_capacity.sh"
echo "Verifica mount:"
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec -T rails \
bundle exec rails runner \
'puts({recordings: `du -sh /recordings 2>/dev/null`.strip, storage: Recordings::Storage.new.backend_name}.inspect)'
echo "Fatto. Volumi Docker obsoleti (rimuovi dopo verifica):"
for v in recordings garage_data garage_meta; do
docker volume inspect "${PROJECT}_${v}" --format '{{ .Name }} ({{ .Mountpoint }})' 2>/dev/null || true
done
echo " docker volume rm ${PROJECT}_recordings ${PROJECT}_garage_data ${PROJECT}_garage_meta"

View File

@@ -14,7 +14,10 @@ bash "${ROOT}/scripts/ensure_garage_prod_config.sh"
echo "Avvio Garage..." echo "Avvio Garage..."
docker compose -f docker-compose.prod.yml --env-file .env up -d garage docker compose -f docker-compose.prod.yml --env-file .env up -d garage
avail_gb="$(df -BG / 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G')" videos_root="${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}"
capacity_df="${videos_root}"
[ -d "$capacity_df" ] || capacity_df="/"
avail_gb="$(df -BG "${capacity_df}" 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G')"
reserve="${GARAGE_RESERVE_GB:-20}" reserve="${GARAGE_RESERVE_GB:-20}"
cap_gb=$((avail_gb - reserve)) cap_gb=$((avail_gb - reserve))
[ "$cap_gb" -lt 10 ] && cap_gb=10 [ "$cap_gb" -lt 10 ] && cap_gb=10