Aggiunge replay YouTube temporaneo, pull registrazioni dai CPX e snapshot ingest in admin.

Così overflow Hetzner e VOD YouTube restano in archivio dopo lo spegnimento del nodo, e la colonna ingest non si svuota.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 13:01:41 +02:00
co-authored by Cursor
parent 35dfa923e3
commit 05ef56c56d
54 changed files with 1843 additions and 201 deletions
+86 -99
View File
@@ -2,156 +2,143 @@
## Panoramica
Al termine di ogni diretta Premium, MediaMTX registra i segmenti. Sidekiq unisce i segmenti, genera thumbnail, carica su **Garage (S3-compatible)** e attiva i servizi di notifica, statistiche e (opzionale) republicazione YouTube.
Al termine di ogni diretta Premium, MediaMTX registra i segmenti. Sidekiq unisce i segmenti, genera thumbnail e carica su **Garage (S3-compatible)**.
- **Live MatchLiveTV (HLS)**: copia **permanente** (policy `retained`) con retention del piano.
- **Live YouTube**: copia **temporanea** di sicurezza (policy `temporary`, default 48h), poi player YouTube in archivio. **Niente** re-upload automatico del MP4 su YouTube.
## Piani e retention
| Piano | Registrazione | Retention | Download MP4 | YouTube VOD |
|-------|---------------|-----------|--------------|-------------|
| Piano | Registrazione | Retention archivio MLTV | Download MP4 | YouTube |
|-------|---------------|-------------------------|--------------|---------|
| Free | No | — | No | No |
| Premium Light | Sì | 30 giorni | Sì | No |
| Premium Full | Sì | 90 giorni | Sì | Sì (auto se diretta YouTube) |
| Premium Light | Sì | 30 giorni (`expires_at`) | Sì (solo se file MLTV) | No |
| Premium Full | Sì | 90 giorni (`expires_at`) se HLS; **nessuna scadenza archivio** se YouTube (`expires_at` nil) | Sì solo con file Garage | Live → VOD nativo; verify collega `youtube_video_id` |
## Storage policy (`Recordings::StoragePolicy`)
```text
if youtube_destination? → temporary
elsif can_create_recordings? → retained
else → none
```
| Policy | Prefisso storage | `expires_at` (archivio) | `temp_expires_at` |
|--------|------------------|-------------------------|-------------------|
| `temporary` | `temporary_replays/...` | `nil` | now + `YOUTUBE_TEMP_REPLAY_RETENTION_HOURS` (2472, default 48) |
| `retained` | `teams/...` | piano `recording_days` | `nil` |
| `none` | — | — | — |
## Archivio logico vs storage fisico
Dopo cleanup della copia temp, la partita **resta** in archivio MatchLiveTV. Cambia solo la sorgente:
| `replay_source` | Player | File Garage permanente |
|-----------------|--------|------------------------|
| `youtube` | embed / link YouTube | no (MP4 temp già eliminato) |
| `matchlivetv` | player MP4 proprietario | sì |
| `none` | non playable | — |
`available_in_archive?` = `ready` e (YouTube URL/`youtube_video_id` **oppure** `storage_key`).
Clear/purge temporary: **solo** oggetti S3 video; mai soft-delete; mai azzerare `youtube_video_id` / metadata / score. Thumbnail preferibilmente conservata.
## Flusso YouTube
```text
Stop → StoragePolicy(temporary) → Finalize (expires_at nil, temp_expires_at)
→ Upload temporary_replays/... → ready
→ VerifyYoutubeReplayJob (backoff)
→ ok: youtube_video_id + youtube_verified_at → ClearTemporaryMediaJob
→ fail fino a max attempts: aspetta safety net
→ PurgeTemporaryMediaJob (cron): a temp_expires_at elimina file + log se non verificato
```
## Funzionalità implementate
### 1. Registrazione automatica
Pipeline: `Sessions::Stop``FinalizeSession``UploadJob` → storage.
Pipeline: `Sessions::Stop``FinalizeSession``UploadJob` → storage`PostProcessJob`.
MediaMTX registra **solo mentre il telefono pubblica** (RTMP connesso). In pausa o con sola slate `alwaysAvailable` la registrazione è disattivata, così il replay non contiene minuti di schermo «Trasmissione in pausa».
MediaMTX registra **solo mentre il telefono pubblica** (RTMP connesso).
### 2. Retention e purge
`Recordings::PurgeExpiredJob` / `rails recordings:purge_expired`
- Retained: `Recordings::PurgeExpiredJob` / `rails recordings:purge_expired` (soft-delete + S3 + YouTube se collegato)
- Temporary: `rails recordings:purge_temporary` (solo storage)
### 3. Archivio Replay
- Web: `/clubs/:id/replays` (gestione società)
- Pubblico: `/replay` con filtro società/squadra
- App mobile: **nessuna** UI replay (gestione solo da sito web)
- Stessa UX listing per YouTube e MLTV (differisce solo il player)
### 4. Riproduzione
- Player MP4: `/replay/:id` + stream `/replay/:id/stream`
- Delivery: Rails verifica accesso → redirect 302 a `/media/...` (edge → Garage). Puma non proxya il file.
- YouTube: embed su `/replay/:id`
- MLTV: MP4 via `/replay/:id/stream` → redirect `/media/...` → Garage
- Contatore visualizzazioni su ogni play (`view_count`)
### 5. Visibilità
- **Pubblico** (`public`): compare in `/replay`, indicizzabile, YouTube `public`
- **Privato** (`unlisted`): non in catalogo pubblico, accessibile solo con link diretto, `noindex`, YouTube `unlisted`
Modificabile dallarchivio società (`/clubs/:id/replays`). Il cambio privacy sincronizza automaticamente il VOD YouTube se presente (`Recordings::SyncYoutubePrivacyJob`).
Pubblico / privato (`unlisted`) come prima. Sync privacy YouTube se `youtube_video_id` presente.
### 6. Eliminazione anticipata
`Recordings::Delete` — elimina video YouTube collegato, oggetti S3/Garage (MP4 + thumbnail) e soft-delete DB.
`Recordings::Delete` manuale — resta distruttiva sul record (distinta dal cleanup temporary automatico).
Stesso comportamento alla scadenza retention (`PurgeExpiredJob` alle 03:00).
### 78. KPI, email, thumbnail, download, views — invariati dove applicabili.
### 7. Dashboard KPI
Replay disponibili, in scadenza (7 gg), spazio occupato, **visualizzazioni totali**.
### 8. Miglioramenti Premium
| Feature | Descrizione |
|---------|-------------|
| **Email replay pronto** | A owner e membri società quando status → `ready` |
| **Email scadenza** | 7 giorni prima di `expires_at` (`rails recordings:expiry_warnings`) |
| **Thumbnail** | Frame ffmpeg, URL `/replay/:id/thumbnail` |
| **Download MP4** | Premium, link presigned 15 min o `/replay/:id/download` |
| **Statistiche views** | `view_count` su ogni replay |
| **metadata JSONB** | `source_platform`, `auto_publish_youtube`, `ai: {}` per estensioni |
| **YouTube VOD** | Premium Full: upload automatico se diretta era su YouTube; manuale da archivio |
## Storage
## Config ENV
```env
YOUTUBE_TEMP_REPLAY_RETENTION_HOURS=48
YOUTUBE_REPLAY_VERIFY_MAX_ATTEMPTS=12
YOUTUBE_REPLAY_VERIFY_BASE_INTERVAL_SECS=300
YOUTUBE_REPLAY_VERIFY_GRACE_SECS=120
REPLAY_STORAGE_ENDPOINT=http://garage:3900
REPLAY_STORAGE_BUCKET=matchlivetv-replays
REPLAY_STORAGE_ACCESS_KEY_ID=...
REPLAY_STORAGE_SECRET_ACCESS_KEY=...
REPLAY_DOWNLOAD_URL_TTL_SECONDS=900
...
```
Valori letti da `MatchLiveTv` (`config/initializers/match_live_tv.rb`).
## Storage Garage
Senza endpoint S3: storage locale `/recordings/replays/`.
### Dev locale con Garage
```bash
cd infra
docker compose up -d # include garage
bash scripts/setup_garage_replays.sh # layout, bucket, chiave → .env
docker compose up -d rails sidekiq
# Verifica pipeline (segmento finto → Garage)
docker compose exec -T rails bundle exec rails replay:e2e_garage
```
### Produzione (`/opt/matchlivetv`)
```bash
cd infra
bash scripts/setup_garage_production.sh # garage.prod.toml, bucket, chiavi → .env
# Rails/Sidekiq usano REPLAY_STORAGE_ENDPOINT=http://garage:3900 (rete Docker)
```
**Credenziali in `.env`:** non compilarle a mano in `.env.example`. Lo script scrive in `infra/.env`:
- `REPLAY_STORAGE_ACCESS_KEY_ID` = Key ID Garage (es. `GK...`)
- `REPLAY_STORAGE_SECRET_ACCESS_KEY` = Secret mostrato una sola volta alla creazione chiave
### Dev / produzione Garage
Vedi script `infra/scripts/setup_garage_*.sh` come in precedenza.
## Cron (produzione)
Installazione sul server:
```bash
cd /opt/matchlivetv/infra
bash scripts/install_production_cron.sh
```
Job attivi (utente `eminux`):
| Orario | Task |
|--------|------|
| 03:00 | `recordings:purge_expired`elimina replay scaduti (DB + Garage) |
| 03:00 | `recordings:purge_expired`replay retained scaduti |
| 03:30 | `recordings:purge_temporary` — copie temp YouTube scadute |
| 08:00 | `recordings:expiry_warnings` — email avviso scadenza (7 gg) |
Log: `${MATCHLIVETV_VIDEOS_ROOT}/log/cron-replay.log` (es. `/media/videos/matchlivetv/log/cron-replay.log`)
### Capacità Garage
Allinstallazione, `setup_garage_production.sh` assegna ~**(disco libero 20 GB)** al nodo.
Per ridimensionare dopo:
### Audit duplicati storici
```bash
bash scripts/garage_set_capacity.sh 85G # oppure senza argomento: calcolo automatico
rails recordings:audit_youtube_duplicates # dry-run di default
```
## Piattaforme live e replay
Nessun cleanup automatico dei vecchi MP4 YouTube già in Garage.
| Origine diretta | Copia Garage/S3 | Player sito `/replay/:id` | YouTube VOD |
|-----------------|-----------------|----------------------------|-------------|
| `matchlivetv` | Sì (Premium) | MP4 via redirect `/media/` → Garage | Manuale (Premium Full) |
| `youtube` | Sì (Premium) | MP4 via redirect `/media/` → Garage | Auto se Premium Full + diretta YouTube |
## API
Se il file S3 non è disponibile ma esiste `youtube_video_id`, la pagina replay mostra embed YouTube.
- `GET /teams/:id/recordings` — include `replay_source`, `youtube_watch_url`, `playback_url` (null se youtube-only), `download_enabled` solo con `storage_key`
- `GET /recordings/:id/download` — richiede file Garage
- `POST /recordings/:id/publish_youtube` — solo MatchLiveTV-only con file (non per live già YouTube)
## Retention e abbonamento
## Colonne DB (migration)
- `expires_at` viene impostato alla fine diretta: **30 giorni** (Premium Light), **90 giorni** (Premium Full)
- `expires_at` **non cambia** al cambio piano successivo
- **Nuove registrazioni** richiedono abbonamento attivo (`can_create_recordings?`)
- **Archivio esistente** resta accessibile fino a `expires_at` anche se labbonamento scade (`can_access_recordings?`)
- `storage_policy` (default `retained`)
- `temp_expires_at`, `youtube_verified_at`, `local_media_purged_at`
## API mobile
## Rischi
- `GET /teams/:id/recordings` — lista con thumbnail, views, download_enabled
- `GET /recordings/:id/download` — URL download temporaneo
- `POST /recordings/:id/publish_youtube` — coda republicazione YouTube
## Estensioni future (metadata.ai)
Campo `metadata["ai"]` predisposto per:
- highlight automatici
- trascrizioni
- articoli generati
- clip / Shorts
Esempio aggiornamento via API:
```json
{ "recording": { "metadata": { "ai": { "transcript_status": "queued" } } } } }
```
- VOD YouTube in ritardo oltre retention temp → safety net elimina file; archivio resta se id collegato; altrimenti anomaly log.
- Thumb: se manca storage, poster YouTube da metadata / default.
- Record storici restano `storage_policy=retained`.