Initial commit: monorepo Match Live TV.

Rails API, app Flutter, infrastruttura Docker/MediaMTX, sito marketing e documentazione di deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-26 17:45:37 +02:00
commit bba6df52c0
381 changed files with 20599 additions and 0 deletions

33
.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# Ruby
backend/.bundle
backend/vendor/bundle
backend/tmp/
backend/log/
backend/storage/
backend/.env
backend/.env.*
# Flutter
mobile/.dart_tool/
mobile/.flutter-plugins
mobile/.flutter-plugins-dependencies
mobile/build/
mobile/.packages
mobile/pubspec.lock
# Infra
infra/certs/
infra/recordings/
infra/.env
infra/.env.production.generated
# OS
.DS_Store
*.swp
.idea/
.vscode/
# Secrets
.env
*.pem
credentials.json

70
README.md Normal file
View File

@@ -0,0 +1,70 @@
# Match Live TV
Piattaforma mobile-first per streaming di partite sportive giovanili — MVP su **Match Live TV** (HLS sul nostro sito); YouTube/Facebook/Twitch come integrazioni premium.
**Slogan:** *Lo streaming che non muore*
## Struttura monorepo
```
├── backend/ # Rails 7.2 API + Action Cable + Sidekiq
├── mobile/ # Flutter app (Camera + Regia)
├── infra/ # Docker Compose, MediaMTX, Nginx, Kamal
└── docs/ # Documentazione
```
## Deploy produzione (server 192.168.1.146)
Vedi [docs/infrastructure/SERVER_DEPLOYMENT.md](docs/infrastructure/SERVER_DEPLOYMENT.md) per porte router, NPM e procedura completa.
**Porte da aprire sul router:** `1935/TCP` (RTMP). Opzionale `8888/TCP` (HLS). HTTPS via Nginx Proxy Manager verso `http://192.168.1.146:3000`.
**Sul server (una tantum, con sudo):**
```bash
sudo bash ~/matchlivetv/scripts/deploy/server_bootstrap.sh
bash ~/matchlivetv/scripts/deploy/post_bootstrap_deploy.sh
```
## Avvio rapido (locale)
```bash
cd infra
cp .env.example .env
docker compose up -d --build
```
- API Rails: http://localhost:3000
- MediaMTX RTMP: rtmp://localhost:1935
- MediaMTX API: http://localhost:9997
- Admin: http://localhost:3000/admin
### Seed utente demo
```
email: coach@matchlivetv.test
password: password123
```
## Mobile
```bash
cd mobile
flutter pub get
flutter run
```
Configura `API_BASE_URL` in `lib/core/config.dart` (default `http://10.0.2.2:3000` per emulatore Android).
## Architettura
- Video (MVP): Phone → MediaMTX (RTMP) → HLS su `matchlivetv.*` (pagina `/live/:session_id`)
- Video (premium): stesso ingest + relay ffmpeg verso YouTube
- Controllo: Phone ↔ Action Cable ↔ Rails ↔ PostgreSQL
- Disconnessioni: MediaMTX `alwaysAvailable` slate + timeout 5 min (Sidekiq)
### Mobile produzione
```bash
./scripts/run_mobile_android_prod.sh
# API: https://matchlivetv.eminux.it
```

42
backend/.dockerignore Normal file
View File

@@ -0,0 +1,42 @@
# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files.
# Ignore git directory.
/.git/
/.gitignore
# Ignore bundler config.
/.bundle
# Ignore all environment files (except templates).
/.env*
!/.env*.erb
# Ignore all default key files.
/config/master.key
/config/credentials/*.key
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
# Ignore pidfiles, but keep the directory.
/tmp/pids/*
!/tmp/pids/.keep
# Ignore storage (uploaded files in development and any SQLite databases).
/storage/*
!/storage/.keep
/tmp/storage/*
!/tmp/storage/.keep
# Ignore CI service files.
/.github
# Ignore development files
/.devcontainer
# Ignore Docker-related files
/.dockerignore
/Dockerfile*

9
backend/.gitattributes vendored Normal file
View File

@@ -0,0 +1,9 @@
# See https://git-scm.com/docs/gitattributes for more about git attribute files.
# Mark the database schema as having been generated.
db/schema.rb linguist-generated
# Mark any vendored files as having been vendored.
vendor/* linguist-vendored
config/credentials/*.yml.enc diff=rails_credentials
config/credentials.yml.enc diff=rails_credentials

12
backend/.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: bundler
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10

39
backend/.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: CI
on:
pull_request:
push:
branches: [ main ]
jobs:
scan_ruby:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Scan for common Rails security vulnerabilities using static analysis
run: bin/brakeman --no-pager
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Lint code for consistent style
run: bin/rubocop -f github

33
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# See https://help.github.com/articles/ignoring-files for more about ignoring files.
#
# Temporary files generated by your text editor or operating system
# belong in git's global ignore instead:
# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore`
# Ignore bundler config.
/.bundle
# Ignore all environment files (except templates).
/.env*
!/.env*.erb
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
# Ignore pidfiles, but keep the directory.
/tmp/pids/*
!/tmp/pids/
!/tmp/pids/.keep
# Ignore storage (uploaded files in development and any SQLite databases).
/storage/*
!/storage/.keep
/tmp/storage/*
!/tmp/storage/
!/tmp/storage/.keep
# Ignore master key for decrypting credentials and more.
/config/master.key

8
backend/.rubocop.yml Normal file
View File

@@ -0,0 +1,8 @@
# Omakase Ruby styling for Rails
inherit_gem: { rubocop-rails-omakase: rubocop.yml }
# Overwrite or add rules to create your own house style
#
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
# Layout/SpaceInsideArrayLiteralBrackets:
# Enabled: false

1
backend/.ruby-version Normal file
View File

@@ -0,0 +1 @@
ruby-3.3.11

19
backend/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM ruby:3.3-bookworm
RUN apt-get update -qq && \
apt-get install -y --no-install-recommends build-essential libpq-dev curl ffmpeg && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV BUNDLE_WITHOUT="development:test"
ENV BUNDLE_DEPLOYMENT="1"
COPY Gemfile Gemfile.lock ./
RUN bundle install
COPY . .
EXPOSE 3000
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb", "-b", "tcp://0.0.0.0:3000"]

33
backend/Gemfile Normal file
View File

@@ -0,0 +1,33 @@
source "https://rubygems.org"
gem "rails", "~> 7.2.2"
gem "pg", "~> 1.1"
gem "puma", ">= 5.0"
gem "bootsnap", require: false
gem "tzinfo-data", platforms: %i[ windows jruby ]
gem "redis", ">= 4.0.1"
gem "bcrypt", "~> 3.1.7"
gem "rack-cors"
gem "jwt"
gem "sidekiq"
gem "rack-attack"
gem "aasm"
gem "attr_encrypted"
gem "google-apis-youtube_v3"
gem "faraday"
gem "stripe"
gem "kamal", require: false
group :development, :test do
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
gem "brakeman", require: false
gem "rubocop-rails-omakase", require: false
gem "rspec-rails"
gem "factory_bot_rails"
gem "faker"
end
group :development do
gem "letter_opener"
end

443
backend/Gemfile.lock Normal file
View File

@@ -0,0 +1,443 @@
GEM
remote: https://rubygems.org/
specs:
aasm (5.5.2)
concurrent-ruby (~> 1.0)
actioncable (7.2.3.1)
actionpack (= 7.2.3.1)
activesupport (= 7.2.3.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.3.1)
actionpack (= 7.2.3.1)
activejob (= 7.2.3.1)
activerecord (= 7.2.3.1)
activestorage (= 7.2.3.1)
activesupport (= 7.2.3.1)
mail (>= 2.8.0)
actionmailer (7.2.3.1)
actionpack (= 7.2.3.1)
actionview (= 7.2.3.1)
activejob (= 7.2.3.1)
activesupport (= 7.2.3.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.3.1)
actionview (= 7.2.3.1)
activesupport (= 7.2.3.1)
cgi
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4, < 3.3)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.3.1)
actionpack (= 7.2.3.1)
activerecord (= 7.2.3.1)
activestorage (= 7.2.3.1)
activesupport (= 7.2.3.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.3.1)
activesupport (= 7.2.3.1)
builder (~> 3.1)
cgi
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.3.1)
activesupport (= 7.2.3.1)
globalid (>= 0.3.6)
activemodel (7.2.3.1)
activesupport (= 7.2.3.1)
activerecord (7.2.3.1)
activemodel (= 7.2.3.1)
activesupport (= 7.2.3.1)
timeout (>= 0.4.0)
activestorage (7.2.3.1)
actionpack (= 7.2.3.1)
activejob (= 7.2.3.1)
activerecord (= 7.2.3.1)
activesupport (= 7.2.3.1)
marcel (~> 1.0)
activesupport (7.2.3.1)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1, < 6)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3)
attr_encrypted (4.2.0)
encryptor (~> 3.0.0)
base64 (0.3.0)
bcrypt (3.1.22)
bcrypt_pbkdf (1.1.2)
bcrypt_pbkdf (1.1.2-arm64-darwin)
bcrypt_pbkdf (1.1.2-x86_64-darwin)
benchmark (0.5.0)
bigdecimal (4.1.2)
bootsnap (1.24.5)
msgpack (~> 1.2)
brakeman (8.0.4)
racc
builder (3.3.0)
cgi (0.5.1)
childprocess (5.1.0)
logger (~> 1.5)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
declarative (0.0.20)
diff-lcs (1.6.2)
dotenv (3.2.0)
drb (2.2.3)
ed25519 (1.4.0)
encryptor (3.0.0)
erb (6.0.4)
erubi (1.13.1)
factory_bot (6.6.0)
activesupport (>= 6.1.0)
factory_bot_rails (6.5.1)
factory_bot (~> 6.5)
railties (>= 6.1.0)
faker (3.8.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-net_http (3.4.3)
net-http (~> 0.5)
globalid (1.3.0)
activesupport (>= 6.1)
google-apis-core (1.0.2)
addressable (~> 2.8, >= 2.8.7)
faraday (~> 2.13)
faraday-follow_redirects (~> 0.3)
googleauth (~> 1.14)
mini_mime (~> 1.1)
representable (~> 3.0)
retriable (~> 3.1)
google-apis-youtube_v3 (0.64.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-env (2.3.1)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-logging-utils (0.2.0)
googleauth (1.16.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
io-console (0.8.2)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.19.5)
jwt (3.2.0)
base64
kamal (2.11.0)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
concurrent-ruby (~> 1.2)
dotenv (~> 3.1)
ed25519 (~> 1.4)
net-ssh (~> 7.3)
sshkit (>= 1.23.0, < 2.0)
thor (~> 1.3)
zeitwerk (>= 2.6.18, < 3.0)
language_server-protocol (3.17.0.5)
launchy (3.1.1)
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.2.1)
mini_mime (1.1.5)
minitest (5.27.0)
msgpack (1.8.0)
multi_json (1.21.1)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.4)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-scp (4.1.0)
net-ssh (>= 2.6.5, < 8.0.0)
net-sftp (4.0.0)
net-ssh (>= 5.0.0, < 8.0.0)
net-smtp (0.5.1)
net-protocol
net-ssh (7.3.2)
nio4r (2.7.5)
nokogiri (1.19.3-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.3-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.3-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.3-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-musl)
racc (~> 1.4)
os (1.1.4)
ostruct (0.6.3)
parallel (2.1.0)
parser (3.3.11.1)
ast (~> 2.4.1)
racc
pg (1.6.3)
pg (1.6.3-aarch64-linux)
pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-darwin)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
psych (5.3.1)
date
stringio
public_suffix (7.0.5)
puma (8.0.1)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.6)
rack-attack (6.8.0)
rack (>= 1.0, < 4)
rack-cors (3.0.0)
logger
rack (>= 3.0.14)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (7.2.3.1)
actioncable (= 7.2.3.1)
actionmailbox (= 7.2.3.1)
actionmailer (= 7.2.3.1)
actionpack (= 7.2.3.1)
actiontext (= 7.2.3.1)
actionview (= 7.2.3.1)
activejob (= 7.2.3.1)
activemodel (= 7.2.3.1)
activerecord (= 7.2.3.1)
activestorage (= 7.2.3.1)
activesupport (= 7.2.3.1)
bundler (>= 1.15.0)
railties (= 7.2.3.1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.2.3.1)
actionpack (= 7.2.3.1)
activesupport (= 7.2.3.1)
cgi
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.4.2)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
tsort
redis (5.4.1)
redis-client (>= 0.22.0)
redis-client (0.29.0)
connection_pool
regexp_parser (2.12.0)
reline (0.6.3)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.5.0)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (8.0.4)
actionpack (>= 7.2)
activesupport (>= 7.2)
railties (>= 7.2)
rspec-core (>= 3.13.0, < 5.0.0)
rspec-expectations (>= 3.13.0, < 5.0.0)
rspec-mocks (>= 3.13.0, < 5.0.0)
rspec-support (>= 3.13.0, < 5.0.0)
rspec-support (3.13.7)
rubocop (1.86.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (>= 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.1)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.35.2)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails-omakase (1.1.0)
rubocop (>= 1.72)
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
securerandom (0.4.1)
sidekiq (8.1.5)
connection_pool (>= 3.0.0)
json (>= 2.16.0)
logger (>= 1.7.0)
rack (>= 3.2.0)
redis-client (>= 0.29.0)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
sshkit (1.25.0)
base64
logger
net-scp (>= 1.1.2)
net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0)
ostruct
stringio (3.2.0)
stripe (19.1.0)
bigdecimal
logger
thor (1.5.0)
timeout (0.6.1)
trailblazer-option (0.1.2)
tsort (0.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uber (0.1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
zeitwerk (2.8.2)
PLATFORMS
aarch64-linux
aarch64-linux-gnu
aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
arm64-darwin
x86_64-darwin
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
DEPENDENCIES
aasm
attr_encrypted
bcrypt (~> 3.1.7)
bootsnap
brakeman
debug
factory_bot_rails
faker
faraday
google-apis-youtube_v3
jwt
kamal
letter_opener
pg (~> 1.1)
puma (>= 5.0)
rack-attack
rack-cors
rails (~> 7.2.2)
redis (>= 4.0.1)
rspec-rails
rubocop-rails-omakase
sidekiq
stripe
tzinfo-data
BUNDLED WITH
2.5.22

24
backend/README.md Normal file
View File

@@ -0,0 +1,24 @@
# README
This README would normally document whatever steps are necessary to get the
application up and running.
Things you may want to cover:
* Ruby version
* System dependencies
* Configuration
* Database creation
* Database initialization
* How to run the test suite
* Services (job queues, cache servers, search engines, etc.)
* Deployment instructions
* ...

6
backend/Rakefile Normal file
View File

@@ -0,0 +1,6 @@
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require_relative "config/application"
Rails.application.load_tasks

View File

@@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end

View File

@@ -0,0 +1,17 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
payload = JsonWebToken.decode(token_from_header)
self.current_user = User.find_by(id: payload[:user_id]) if payload
reject_unauthorized_connection unless current_user
end
private
def token_from_header
request.headers["Authorization"].to_s.delete_prefix("Bearer ").presence
end
end
end

View File

@@ -0,0 +1,9 @@
module CableBroadcastable
extend ActiveSupport::Concern
class_methods do
def broadcast_message(session, data)
broadcast_to(session, method: "receive_message", data: data)
end
end
end

View File

@@ -0,0 +1,83 @@
class SessionChannel < ApplicationCable::Channel
include CableBroadcastable
def subscribed
session = StreamSession.joins(match: :team)
.merge(current_user.teams)
.find(params[:session_id])
stream_for session
session.device_states.find_or_create_by!(device_role: params[:device_role] || "controller")
end
def unsubscribed
stop_all_streams
end
def receive(data)
session = StreamSession.find(params[:session_id])
handle_message(session, data)
end
private
def handle_message(session, data)
case data["type"]
when "command"
SessionChannel.broadcast_message(session, data)
execute_command(session, data)
when "score_update"
update_score(session, data)
when "timeout"
update_timeout(session, data)
when "device_state"
update_device(session, data)
end
end
def execute_command(session, data)
case data["action"]
when "start_stream"
Sessions::Start.new(session).call
when "stop_stream"
Sessions::Stop.new(session).call
when "pause_stream"
Sessions::Pause.new(session).call
end
end
def update_score(session, data)
score = session.score_state || session.create_score_state!
score.update!(
home_sets: data["home_sets"] || score.home_sets,
away_sets: data["away_sets"] || score.away_sets,
home_points: data["home_points"] || score.home_points,
away_points: data["away_points"] || score.away_points,
current_set: data["current_set"] || score.current_set,
set_partials: data.key?("set_partials") ? data["set_partials"] : score.set_partials
)
SessionChannel.broadcast_message(session, score.as_cable_payload)
end
def update_timeout(session, data)
score = session.score_state
return unless score
if data["team"] == "home"
score.update!(timeout_home: true)
else
score.update!(timeout_away: true)
end
SessionChannel.broadcast_message(session, { type: "timeout", team: data["team"] })
end
def update_device(session, data)
state = session.device_states.find_or_initialize_by(device_role: data["device_role"] || "camera")
state.update!(
battery_level: data["battery"],
network_type: data["network"],
current_bitrate: data["bitrate"],
fps: data["fps"],
last_seen_at: Time.current
)
SessionChannel.broadcast_message(session, state.as_cable_payload)
end
end

View File

@@ -0,0 +1,6 @@
module Admin
class BaseController < ActionController::Base
layout "admin"
protect_from_forgery with: :null_session
end
end

View File

@@ -0,0 +1,8 @@
module Admin
class DashboardController < Admin::BaseController
def index
@teams = Team.includes(:matches).order(:name)
@active_sessions = StreamSession.where(status: %w[live reconnecting connecting]).includes(:match)
end
end
end

View File

@@ -0,0 +1,12 @@
module Admin
class SessionsController < Admin::BaseController
def index
@sessions = StreamSession.includes(:match, :user).order(created_at: :desc).limit(50)
end
def show
@session = StreamSession.find(params[:id])
@events = @session.stream_events.recent.limit(50)
end
end
end

View File

@@ -0,0 +1,12 @@
module Admin
class TeamsController < Admin::BaseController
def index
@teams = Team.all.order(:name)
end
def show
@team = Team.find(params[:id])
@matches = @team.matches.order(scheduled_at: :desc)
end
end
end

View File

@@ -0,0 +1,65 @@
module Api
module V1
class AuthController < ApplicationController
skip_before_action :authenticate_request!, only: %i[login refresh register]
def register
user = User.new(
email: params[:email]&.downcase,
name: params[:name],
password: params[:password],
role: "coach"
)
if user.save
render json: token_response(user), status: :created
else
render json: { error: user.errors.full_messages.join(", ") }, status: :unprocessable_entity
end
end
def login
user = User.find_by(email: params[:email]&.downcase)
if user&.authenticate(params[:password])
render json: token_response(user), status: :ok
else
render json: { error: "Invalid credentials" }, status: :unauthorized
end
end
def logout
render json: { message: "Logged out" }
end
def refresh
payload = JsonWebToken.decode(params[:refresh_token] || bearer_token)
user = User.find_by(id: payload&.dig(:user_id))
return render json: { error: "Invalid token" }, status: :unauthorized unless user
render json: token_response(user)
end
def me
render json: user_json(current_user)
end
private
def token_response(user)
{
user: user_json(user),
access_token: JsonWebToken.encode({ user_id: user.id, type: "access" }),
refresh_token: JsonWebToken.encode({ user_id: user.id, type: "refresh" }, exp: 30.days.from_now)
}
end
def user_json(user)
{
id: user.id,
email: user.email,
name: user.name,
role: user.role
}
end
end
end
end

View File

@@ -0,0 +1,17 @@
module Api
module V1
class BaseController < ApplicationController
rescue_from Teams::EntitlementError, with: :render_entitlement_error
private
def render_entitlement_error(error)
render json: {
error: error.message,
error_code: error.code,
billing_url: error.billing_url
}, status: :forbidden
end
end
end
end

View File

@@ -0,0 +1,85 @@
module Api
module V1
class MatchesController < BaseController
before_action :set_team, only: %i[index create]
before_action :set_match, only: %i[show update destroy]
def index
matches = @team.matches
.includes(:team, :stream_sessions)
.order(scheduled_at: :desc)
render json: matches.map { |m| match_json(m) }
end
def create
match = @team.matches.create!(match_params)
render json: match_json(match), status: :created
end
def show
render json: match_json(@match, detail: true)
end
def update
@match.update!(match_params)
render json: match_json(@match)
end
def destroy
active = active_session_for(@match)
if active&.resumable?
return render json: {
error: "Chiudi la diretta prima di eliminare questa partita"
}, status: :unprocessable_entity
end
@match.destroy!
head :no_content
end
private
def set_team
@team = current_user.teams.find(params[:team_id])
end
def set_match
@match = Match.joins(:team).merge(current_user.teams).find(params[:id])
end
def match_params
params.require(:match).permit(
:opponent_name, :location, :scheduled_at, :sport, :sets_to_win,
:category, :phase, roster_numbers: [],
scoring_rules: %i[points_per_set points_deciding_set min_point_lead]
)
end
def match_json(match, detail: false)
active = active_session_for(match)
{
id: match.id,
team_id: match.team_id,
team_name: match.team.name,
opponent_name: match.opponent_name,
location: match.location,
scheduled_at: match.scheduled_at,
sport: match.sport,
sets_to_win: match.sets_to_win,
scoring_rules: match.scoring_rules.presence,
roster_numbers: match.roster_numbers,
category: match.category,
phase: match.phase,
active_session_id: active&.id,
active_session_status: active&.status
}
end
def active_session_for(match)
match.stream_sessions
.order(created_at: :desc)
.find { |s| !%w[ended error].include?(s.status) }
end
end
end
end

View File

@@ -0,0 +1,146 @@
module Api
module V1
class StreamSessionsController < BaseController
before_action :set_session, except: :create
def create
match = Match.joins(:team).merge(current_user.teams).find(params[:match_id])
session = Sessions::Create.new(user: current_user, match: match, params: session_params).call
render json: session_json(session), status: :created
end
def show
render json: session_json(@session, detail: true)
end
def start
Sessions::Start.new(@session).call
render json: session_json(@session)
end
def stop
Sessions::Stop.new(@session).call
render json: session_json(@session)
end
def pause
Sessions::Pause.new(@session).call
render json: session_json(@session)
end
def events
events = @session.stream_events.recent.limit(100)
render json: events.map { |e| event_json(e) }
end
def telemetry
role = params.require(:device_role)
state = @session.device_states.find_or_initialize_by(device_role: role)
state.update!(
battery_level: params[:battery_level],
network_type: params[:network_type],
signal_strength: params[:signal_strength],
current_bitrate: params[:current_bitrate],
target_bitrate: params[:target_bitrate],
fps: params[:fps],
last_seen_at: Time.current
)
SessionChannel.broadcast_message(@session, state.as_cable_payload)
head :no_content
end
def pairing_token
token = SecureRandom.urlsafe_base64(24)
@session.update!(
pairing_token_digest: Digest::SHA256.hexdigest(token),
pairing_token_expires_at: 15.minutes.from_now
)
render json: {
pairing_token: token,
expires_at: @session.pairing_token_expires_at,
qr_payload: {
session_id: @session.id,
pairing_token: token,
api_url: request.base_url
}
}
end
def claim_pairing
token = params.require(:pairing_token)
digest = Digest::SHA256.hexdigest(token)
unless @session.pairing_token_digest == digest &&
@session.pairing_token_expires_at&.future?
return render json: { error: "Invalid or expired token" }, status: :unauthorized
end
@session.device_states.find_or_create_by!(device_role: params[:device_role] || "controller")
render json: session_json(@session)
end
def network_test
@session.stream_events.create!(
event_type: "network_test",
metadata: params.permit(:download_mbps, :upload_mbps, :latency_ms, :network_type).to_h,
occurred_at: Time.current
)
ready = params[:upload_mbps].to_f >= (@session.target_bitrate / 1_000_000.0 * 0.8)
render json: { ready: ready, target_upload_mbps: @session.target_bitrate / 1_000_000.0 }
end
def youtube_stats
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
Youtube::BroadcastService.new(@session.match.team)
.viewer_count(@session.youtube_broadcast_id)
else
0
end
render json: { concurrent_viewers: count, live: @session.live? }
end
private
def set_session
@session = StreamSession.joins(match: :team)
.merge(current_user.teams)
.find(params[:id])
end
def session_params
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps)
end
def session_json(session, detail: false)
data = {
id: session.id,
match_id: session.match_id,
status: session.status,
platform: session.platform,
rtmp_ingest_url: session.rtmp_ingest_url,
hls_playback_url: session.hls_playback_url,
watch_page_url: session.watch_page_url,
youtube_broadcast_id: session.youtube_broadcast_id,
privacy_status: session.privacy_status,
quality_preset: session.quality_preset,
target_bitrate: session.target_bitrate,
started_at: session.started_at,
disconnection_count: session.disconnection_count
}
if detail
data[:score] = session.score_state&.as_cable_payload
data[:devices] = session.device_states.map(&:as_cable_payload)
end
data
end
def event_json(event)
{
id: event.id,
event_type: event.event_type,
metadata: event.metadata,
occurred_at: event.occurred_at
}
end
end
end
end

View File

@@ -0,0 +1,122 @@
module Api
module V1
class TeamsController < BaseController
def index
teams = current_user.teams.includes(:matches)
render json: teams.map { |t| team_json(t) }
end
def show
team = current_user.teams.find(params[:id])
render json: team_json(team, detail: true)
end
def create
if current_user.user_teams.where(role: "owner").count >= 1
return render json: {
error: "Puoi gestire una sola squadra. Usa il sito per inviti o contattaci."
}, status: :unprocessable_entity
end
team = Team.create!(team_params)
UserTeam.create!(user: current_user, team: team, role: "owner")
Billing::AssignPlan.call(team: team, plan_slug: "free")
render json: team_json(team), status: :created
end
def recordings
team = current_user.teams.find(params[:id])
unless team.entitlements.can_access_recordings?
return render json: {
error: "Archivio gare disponibile con Premium Light o Full",
error_code: "premium_required",
billing_url: team.entitlements.billing_url
}, status: :forbidden
end
recs = team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(50)
render json: recs.map { |r| recording_json(r) }
end
def update
team = current_user.teams.find(params[:id])
team.update!(team_params)
render json: team_json(team)
end
def add_member
team = current_user.teams.find(params[:id])
team.entitlements.assert_can_invite!
member = User.find(params[:user_id])
UserTeam.find_or_create_by!(user: member, team: team) { |ut| ut.role = "member" }
head :no_content
end
def remove_member
team = current_user.teams.find(params[:id])
ut = team.user_teams.find_by!(user_id: params[:user_id], role: "member")
ut.destroy!
head :no_content
end
private
def team_params
params.require(:team).permit(:name, :sport, :logo_url)
end
def team_json(team, detail: false)
ent = team.entitlements
data = {
id: team.id,
name: team.name,
sport: team.sport,
logo_url: team.logo_url,
youtube_connected: team.youtube_credential.present?,
plan_slug: ent.plan.slug,
plan_name: ent.plan.name,
premium_active: ent.premium_active?,
premium_full: ent.premium_full?,
subscription_status: ent.subscription.status,
features: ent.plan.features,
billing_url: ent.billing_url,
staff_manage_url: ent.staff_manage_url,
max_staff: ent.max_staff,
max_staff_transmission: ent.max_staff_transmission,
max_staff_regia: ent.max_staff_regia,
staff_used: ent.staff_count,
staff_transmission_used: ent.staff_count_for("transmission"),
staff_regia_used: ent.staff_count_for("regia"),
concurrent_streams_used: ent.concurrent_streams_used,
concurrent_streams_limit: ent.concurrent_streams_limit,
recordings_enabled: ent.can_access_recordings?,
phone_download_enabled: ent.phone_download_enabled?,
youtube_enabled: ent.youtube_enabled?,
youtube_mode: ent.plan.youtube_mode
}
if detail
data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut|
{ id: ut.user.id, name: ut.user.name, email: ut.user.email, role: ut.role }
end
end
data
end
def recording_json(recording)
session = recording.stream_session
match = session.match
{
id: recording.id,
session_id: session.id,
match_id: match.id,
opponent_name: match.opponent_name,
team_name: match.team.name,
ended_at: session.ended_at,
replay_url: recording.replay_url,
download_url: recording.replay_url,
expires_at: recording.expires_at
}
end
end
end
end

View File

@@ -0,0 +1,26 @@
module Api
module V1
class YoutubeController < BaseController
def authorize
team = current_user.teams.find(params[:team_id])
team.entitlements.assert_can_connect_youtube!
url = Youtube::OauthUrl.build(team_id: team.id, redirect_uri: ENV.fetch("YOUTUBE_REDIRECT_URI"))
render json: { authorization_url: url }
end
def callback
team = current_user.teams.find(params[:state])
tokens = Youtube::OauthExchange.call(params[:code])
cred = team.youtube_credential || team.build_youtube_credential
cred.update!(
access_token: tokens[:access_token],
refresh_token: tokens[:refresh_token],
expires_at: Time.current + tokens[:expires_in].seconds,
channel_id: tokens[:channel_id],
channel_title: tokens[:channel_title]
)
redirect_to "/admin/teams/#{team.id}?youtube=connected"
end
end
end
end

View File

@@ -0,0 +1,25 @@
class ApplicationController < ActionController::API
include ActionController::HttpAuthentication::Token::ControllerMethods
before_action :authenticate_request!
attr_reader :current_user
private
def authenticate_request!
token = bearer_token
payload = JsonWebToken.decode(token)
@current_user = User.find_by(id: payload[:user_id]) if payload
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user
end
def bearer_token
auth = request.headers["Authorization"].to_s
auth.start_with?("Bearer ") ? auth.split(" ", 2).last : nil
end
def skip_auth?
false
end
end

View File

View File

@@ -0,0 +1,44 @@
class HlsProxyController < ActionController::Base
# Proxy pubblico verso MediaMTX HLS (NPM inoltra tutto a Rails :3000).
def show
upstream_path = params[:path].to_s
return head :not_found if upstream_path.blank?
upstream = "#{MatchLiveTv.mediamtx_hls_url}/#{upstream_path}"
upstream = "#{upstream}?#{request.query_string}" if request.query_string.present?
cookie = request.headers["Cookie"].presence || "cookieCheck=1"
response = hls_conn.get(upstream) { |req| req.headers["Cookie"] = cookie }
if response.status == 302
location = response.headers["location"].to_s
follow_url = if location.start_with?("http")
location
else
"#{MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "")}#{location}"
end
set_cookie = response.headers["set-cookie"].to_s
cookie = set_cookie.presence || cookie
response = hls_conn.get(follow_url) { |req| req.headers["Cookie"] = cookie }
end
response.headers.each do |key, value|
next if %w[transfer-encoding connection].include?(key.downcase)
headers[key] = value
end
render body: response.body, status: response.status
rescue Faraday::Error => e
Rails.logger.warn("[HlsProxy] #{upstream_path}: #{e.message}")
head :bad_gateway
end
private
def hls_conn
@hls_conn ||= Faraday.new do |f|
f.adapter Faraday.default_adapter
f.options.timeout = 15
f.options.open_timeout = 5
end
end
end

View File

@@ -0,0 +1,28 @@
module Public
class InvitationsController < WebBaseController
def show
@token = params[:token]
@invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(@token.to_s))
unless @invitation
redirect_to public_pricing_path, alert: "Invito non valido o scaduto"
end
end
def accept
invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(params[:token].to_s))
return redirect_to public_pricing_path, alert: "Invito non valido" unless invitation
if logged_in?
if current_user.email.downcase != invitation.email.downcase
redirect_to public_pricing_path, alert: "Questo invito è per #{invitation.email}"
return
end
invitation.accept!(current_user)
redirect_to public_team_dashboard_path(invitation.team), notice: "Sei entrato nella squadra!"
else
session[:pending_invite_token] = params[:token]
redirect_to public_signup_path, notice: "Registrati con #{invitation.email} per accettare l'invito"
end
end
end
end

View File

@@ -0,0 +1,47 @@
module Public
class LiveController < SiteBaseController
layout "marketing_live"
ACTIVE_STATUSES = %w[live connecting reconnecting].freeze
def index
@query = params[:q].to_s.strip
@sessions = StreamSession
.broadcasting
.includes(:score_state, match: :team)
.search_by_team_or_opponent(@query)
.order(Arel.sql("started_at DESC NULLS LAST"), created_at: :desc)
@online_paths = Mediamtx::Client.new.online_path_names
end
def show
@session = StreamSession.includes(match: :team).find(params[:id])
@match = @session.match
@stream_closed = @session.terminal?
@on_air = !@stream_closed && mediamtx_online?(@session)
end
def status
session = StreamSession.includes(match: :team).find(params[:id])
closed = session.terminal?
render json: {
status: session.status,
stream_closed: closed,
live: !closed && (session.live? || mediamtx_online?(session)),
on_air: !closed && mediamtx_online?(session),
ended_at: session.ended_at,
home_name: session.match.team.name,
away_name: session.match.opponent_name,
score: session.score_state&.as_cable_payload
}
end
private
def mediamtx_online?(session)
@online_paths_cache ||= Mediamtx::Client.new.online_path_names
@online_paths_cache.include?(session.mediamtx_path_name)
end
end
end

View File

@@ -0,0 +1,19 @@
module Public
class PagesController < WebBaseController
def home
end
def features
end
def pricing
@plans = Plan.ordered
end
def privacy
end
def terms
end
end
end

View File

@@ -0,0 +1,33 @@
module Public
class RegistrationsController < WebBaseController
def new
redirect_to public_team_dashboard_path(current_user.teams.first) if logged_in? && current_user.teams.any?
@user = User.new
end
def create
@user = User.new(user_params.merge(role: "coach"))
if @user.save
session[:user_id] = @user.id
if session[:pending_invite_token].present?
token = session.delete(:pending_invite_token)
invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token))
if invitation && invitation.email.downcase == @user.email.downcase
invitation.accept!(@user)
return redirect_to public_team_dashboard_path(invitation.team), notice: "Benvenuto nella squadra!"
end
end
redirect_to new_public_team_path, notice: "Account creato. Ora crea la tua squadra."
else
flash.now[:alert] = @user.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(:name, :email, :password, :password_confirmation)
end
end
end

View File

@@ -0,0 +1,15 @@
module Public
class ReplayController < SiteBaseController
layout "marketing_live"
def show
@session = StreamSession.includes(match: :team).find(params[:id])
@recording = Recording.ready.find_by(stream_session: @session)
@match = @session.match
unless @recording
redirect_to public_live_path(@session), alert: "Replay non disponibile"
end
end
end
end

View File

@@ -0,0 +1,24 @@
module Public
class SessionsController < WebBaseController
def new
redirect_to public_team_dashboard_path(current_user.teams.first) if logged_in? && current_user.teams.any?
end
def create
user = User.find_by(email: params[:email]&.downcase)
if user&.authenticate(params[:password])
session[:user_id] = user.id
dest = user.teams.first ? public_team_dashboard_path(user.teams.first) : new_public_team_path
redirect_to dest, notice: "Bentornato!"
else
flash.now[:alert] = "Email o password non validi"
render :new, status: :unauthorized
end
end
def destroy
reset_session
redirect_to public_pricing_path, notice: "Disconnesso"
end
end
end

View File

@@ -0,0 +1,19 @@
module Public
class SiteBaseController < ActionController::Base
layout "marketing"
helper_method :current_user, :logged_in?
private
def current_user
return @current_user if defined?(@current_user)
@current_user = User.find_by(id: session[:user_id]) if session[:user_id]
end
def logged_in?
current_user.present?
end
end
end

View File

@@ -0,0 +1,119 @@
module Public
class TeamsController < WebBaseController
before_action :require_login!
before_action :set_team, only: %i[
dashboard billing checkout portal invite create_invitation
remove_member destroy_invitation
]
def new
redirect_to public_team_dashboard_path(current_user.teams.first) if current_user.teams.any?
end
def create
if current_user.user_teams.where(role: "owner").exists?
redirect_to public_team_dashboard_path(current_user.teams.first), alert: "Hai già una squadra"
return
end
team = Team.create!(team_params)
UserTeam.create!(user: current_user, team: team, role: "owner")
plan = params[:plan].presence_in(%w[free premium_light premium_full]) || "free"
Billing::AssignPlan.call(team: team, plan_slug: plan)
if plan.in?(%w[premium_light premium_full]) && MatchLiveTv.stripe_enabled?
redirect_to public_team_checkout_path(team, plan: plan)
else
redirect_to public_team_dashboard_path(team), notice: "Squadra creata con piano Free"
end
end
def dashboard
require_team_owner!(@team)
@entitlements = @team.entitlements
@recordings = @team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(10)
@members = @team.user_teams.includes(:user).where.not(role: "owner")
@pending_invitations = @team.team_invitations.pending.order(created_at: :desc)
end
def billing
require_team_owner!(@team)
@entitlements = @team.entitlements
@plans = Plan.ordered
end
def checkout
require_team_owner!(@team)
unless MatchLiveTv.stripe_enabled?
redirect_to public_team_billing_path(@team), alert: "Pagamenti non ancora configurati sul server"
return
end
plan_slug = params[:plan].presence_in(%w[premium_light premium_full]) || "premium_light"
url = Billing::Stripe::CheckoutSession.new(team: @team, user: current_user, plan_slug: plan_slug).url
redirect_to url, allow_other_host: true
end
def portal
require_team_owner!(@team)
unless MatchLiveTv.stripe_enabled?
redirect_to public_team_billing_path(@team), alert: "Portale pagamenti non configurato"
return
end
url = Billing::Stripe::CheckoutSession.new(team: @team, user: current_user).portal_url
redirect_to url, allow_other_host: true
end
def invite
require_team_owner!(@team)
@entitlements = @team.entitlements
end
def create_invitation
require_team_owner!(@team)
@entitlements = @team.entitlements
staff_kind = params[:staff_kind].presence_in(TeamInvitation::STAFF_KINDS) || "transmission"
@entitlements.assert_can_invite!(staff_kind: staff_kind)
email = params[:email]&.downcase&.strip
token = TeamInvitation.generate_token
@team.team_invitations.create!(
email: email,
token_digest: Digest::SHA256.hexdigest(token),
role: "member",
staff_kind: staff_kind,
expires_at: 7.days.from_now
)
@invite_url = join_public_invitation_url(token: token)
flash.now[:notice] = "Link invito generato (valido 7 giorni)"
render :invite
rescue Teams::EntitlementError => e
redirect_to public_team_invite_path(@team), alert: e.message
end
def remove_member
require_team_owner!(@team)
ut = @team.user_teams.find_by!(user_id: params[:user_id], role: "member")
ut.destroy!
redirect_to public_team_dashboard_path(@team), notice: "Accesso revocato"
end
def destroy_invitation
require_team_owner!(@team)
inv = @team.team_invitations.pending.find(params[:invitation_id])
inv.destroy!
redirect_to public_team_dashboard_path(@team), notice: "Invito annullato"
end
private
def set_team
@team = current_user.teams.find(params[:id])
end
def team_params
params.require(:team).permit(:name, :sport, :logo_url)
end
end
end

View File

@@ -0,0 +1,18 @@
module Public
class WebBaseController < SiteBaseController
protect_from_forgery with: :exception
private
def require_login!
return if logged_in?
redirect_to public_login_path, alert: "Accedi per continuare"
end
def require_team_owner!(team)
membership = current_user.user_teams.find_by(team: team)
redirect_to public_prezzi_path, alert: "Accesso non consentito" unless membership&.role == "owner"
end
end
end

View File

@@ -0,0 +1,53 @@
module Webhooks
class MediamtxController < ApplicationController
skip_before_action :authenticate_request!
before_action :verify_signature!
def connect
handle_event("connect")
end
def disconnect
handle_event("disconnect")
end
def ready
handle_event("ready")
end
def validate_publish
session_id = params[:session_id] || extract_session_id(params[:path])
token = params[:token]
session = StreamSession.find_by(id: session_id)
valid = session && session.publish_token == token && !session.ended?
render json: { valid: valid }, status: valid ? :ok : :forbidden
end
private
def handle_event(event)
session_id = params[:session_id].presence || extract_session_id(params[:path])
Webhooks::MediamtxHandler.new(
event: event,
session_id: session_id,
metadata: params.permit!.to_h.except("controller", "action")
).call
head :ok
end
def extract_session_id(path)
path.to_s[%r{match_([0-9a-f-]+)}, 1]
end
def verify_signature!
signature = request.headers["X-MediaMTX-Signature"]
payload = request.raw_post.presence || request.query_string
return if Webhooks::Signature.valid?(payload, signature)
return if Rails.env.development? && signature.blank?
head :unauthorized
end
end
end

View File

@@ -0,0 +1,32 @@
module Webhooks
class StripeController < ActionController::API
def create
payload = request.body.read
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
unless MatchLiveTv.stripe_webhook_secret.present?
Rails.logger.info("[Stripe webhook] ignored — STRIPE_WEBHOOK_SECRET not set")
return head :ok
end
event = ::Stripe::Webhook.construct_event(
payload, sig_header, MatchLiveTv.stripe_webhook_secret
)
Billing::Stripe::WebhookHandler.call(event)
head :ok
rescue JSON::ParserError, ::Stripe::SignatureVerificationError => e
Rails.logger.warn("[Stripe webhook] #{e.message}")
head :bad_request
end
private
def stripe_object_from_hash(hash)
OpenStruct.new(hash.transform_keys(&:to_s).transform_values do |v|
v.is_a?(Hash) ? stripe_object_from_hash(v) : v
end)
end
end
end

View File

@@ -0,0 +1,35 @@
module Public
module LiveHelper
def live_score_sets_label(score_state)
return nil unless score_state
"Set #{score_state.current_set} · Set vinti #{score_state.home_sets}-#{score_state.away_sets}"
end
def live_score_partials_label(score_state)
return nil unless score_state
partials = Array(score_state.set_partials)
return nil if partials.empty?
partials.map { |p| format_set_partial_entry(p) }.join(" · ")
end
def live_score_points_label(match, score_state)
return "" unless score_state
"#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}"
end
private
def format_set_partial_entry(partial)
data = partial.respond_to?(:with_indifferent_access) ? partial.with_indifferent_access : partial
set_no = data[:set] || data["set"]
home = data[:home] || data["home"]
away = data[:away] || data["away"]
label = set_no.present? ? "Set #{set_no}" : "Set"
"#{label} #{home}-#{away}"
end
end
end

View File

@@ -0,0 +1,7 @@
class ApplicationJob < ActiveJob::Base
# Automatically retry jobs that encountered a deadlock
# retry_on ActiveRecord::Deadlocked
# Most jobs are safe to ignore if the underlying records are no longer available
# discard_on ActiveJob::DeserializationError
end

View File

@@ -0,0 +1,12 @@
class CleanupExpiredSessionsJob
include Sidekiq::Job
def perform
StreamSession.where(status: %w[connecting reconnecting])
.where("updated_at < ?", 6.hours.ago)
.find_each do |session|
session.fail! if session.may_fail?
Mediamtx::Client.new.delete_path(session)
end
end
end

View File

@@ -0,0 +1,22 @@
class DisconnectionTimeoutJob
include Sidekiq::Job
def perform(session_id)
session = StreamSession.find_by(id: session_id)
return unless session&.reconnecting?
session.finish! if session.may_finish?
session.update!(timeout_job_id: nil)
session.stream_events.create!(
event_type: "error",
metadata: { reason: "reconnect_timeout" },
occurred_at: Time.current
)
SessionChannel.broadcast_message(session, {
type: "stream_event",
event: "timeout_ended",
message: "Session ended after reconnect timeout"
})
Sessions::Stop.new(session).call
end
end

View File

@@ -0,0 +1,17 @@
class NotifyStreamHealthJob
include Sidekiq::Job
def perform(session_id)
session = StreamSession.find_by(id: session_id)
return unless session&.live?
camera = session.device_states.find_by(device_role: "camera")
return unless camera&.current_bitrate && camera.current_bitrate < session.target_bitrate * 0.5
SessionChannel.broadcast_message(session, {
type: "stream_event",
event: "quality_degraded",
bitrate: camera.current_bitrate
})
end
end

View File

@@ -0,0 +1,16 @@
class StreamAnalyticsJob
include Sidekiq::Job
def perform(session_id)
session = StreamSession.find_by(id: session_id)
return unless session&.ended?
duration = session.total_duration_secs
disconnects = session.disconnection_count
session.stream_events.create!(
event_type: "quality_changed",
metadata: { analytics: true, duration: duration, disconnects: disconnects },
occurred_at: Time.current
)
end
end

View File

@@ -0,0 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout "mailer"
end

View File

@@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end

View File

View File

@@ -0,0 +1,19 @@
class DeviceState < ApplicationRecord
ROLES = %w[camera controller].freeze
belongs_to :stream_session
validates :device_role, inclusion: { in: ROLES }
validates :device_role, uniqueness: { scope: :stream_session_id }
def as_cable_payload
{
type: "device_state",
battery: battery_level,
network: network_type,
bitrate: current_bitrate,
fps: fps,
status: stream_session.status
}
end
end

View File

@@ -0,0 +1,7 @@
class Match < ApplicationRecord
belongs_to :team
has_many :stream_sessions, dependent: :destroy
validates :opponent_name, presence: true
validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 }
end

View File

@@ -0,0 +1,86 @@
class Plan < ApplicationRecord
SLUGS = %w[free premium_light premium_full].freeze
EXTERNAL_PLATFORMS = %w[youtube facebook twitch].freeze
PREMIUM_SLUGS = %w[premium_light premium_full].freeze
has_many :subscriptions, dependent: :restrict_with_error
validates :slug, presence: true, uniqueness: true, inclusion: { in: SLUGS }
validates :name, presence: true
scope :ordered, -> {
order(Arel.sql("CASE slug WHEN 'free' THEN 0 WHEN 'premium_light' THEN 1 ELSE 2 END"))
}
def self.[](slug)
find_by!(slug: slug.to_s)
end
def allows_platform?(platform)
return false if platform.to_s == "youtube" && !youtube_enabled?
Array(features["platforms"]).map(&:to_s).include?(platform.to_s)
end
def feature(key)
features[key.to_s]
end
def recordings_enabled?
feature("recordings_enabled") == true
end
def recording_days
feature("recording_days").to_i
end
def max_staff_transmission
val = feature("max_staff_transmission")
return legacy_max_staff if val.nil? && feature("max_staff").present?
val.nil? ? nil : val.to_i
end
def max_staff_regia
val = feature("max_staff_regia")
return legacy_max_staff if val.nil? && feature("max_staff").present?
val.nil? ? nil : val.to_i
end
def max_staff
t = max_staff_transmission
r = max_staff_regia
return legacy_max_staff if legacy_max_staff.positive? && t.nil? && r.nil? && feature("max_staff").present?
return nil if t.nil? && r.nil?
(t || 0) + (r || 0)
end
def youtube_mode
feature("youtube_mode")&.to_s
end
def concurrent_streams_limit
val = feature("concurrent_streams_limit")
val.nil? ? nil : val.to_i
end
def phone_download_enabled?
feature("phone_download_enabled") == true
end
def youtube_enabled?
feature("youtube_enabled") == true
end
def premium?
slug.in?(PREMIUM_SLUGS)
end
private
def legacy_max_staff
feature("max_staff").to_i
end
end

View File

@@ -0,0 +1,21 @@
class Recording < ApplicationRecord
STATUSES = %w[processing ready expired].freeze
belongs_to :stream_session
belongs_to :team
validates :status, inclusion: { in: STATUSES }
scope :ready, -> { where(status: "ready").where("expires_at IS NULL OR expires_at > ?", Time.current) }
scope :for_team, ->(team) { where(team: team) }
def replay_url
return nil unless ready? && stream_session_id.present?
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}"
end
def ready?
status == "ready" && (expires_at.nil? || expires_at.future?)
end
end

View File

@@ -0,0 +1,17 @@
class ScoreState < ApplicationRecord
belongs_to :stream_session
def as_cable_payload
{
type: "score_update",
home_sets: home_sets,
away_sets: away_sets,
home_points: home_points,
away_points: away_points,
current_set: current_set,
set_partials: set_partials || [],
timeout_home: timeout_home,
timeout_away: timeout_away
}
end
end

View File

@@ -0,0 +1,13 @@
class StreamEvent < ApplicationRecord
EVENT_TYPES = %w[
connected disconnected reconnected quality_changed error paused resumed
started ended network_test pairing
].freeze
belongs_to :stream_session
validates :event_type, inclusion: { in: EVENT_TYPES }
validates :occurred_at, presence: true
scope :recent, -> { order(occurred_at: :desc) }
end

View File

@@ -0,0 +1,150 @@
class StreamSession < ApplicationRecord
include AASM
PLATFORMS = %w[matchlivetv youtube facebook twitch].freeze
STATUSES = %w[idle connecting live reconnecting paused ended error].freeze
belongs_to :match
belongs_to :user
has_many :stream_events, dependent: :destroy
has_one :score_state, dependent: :destroy
has_many :device_states, dependent: :destroy
validates :platform, inclusion: { in: PLATFORMS }
validates :status, inclusion: { in: STATUSES }
before_validation :ensure_publish_token, on: :create
scope :broadcasting, -> { where(status: %w[live connecting reconnecting]) }
scope :search_by_team_or_opponent, lambda { |query|
q = query.to_s.strip
return all if q.blank?
term = "%#{sanitize_sql_like(q)}%"
joins(match: :team).where(
"teams.name ILIKE :term OR matches.opponent_name ILIKE :term",
term: term
)
}
aasm column: :status do
state :idle, initial: true
state :connecting, :live, :reconnecting, :paused, :ended, :error
event :begin_connect do
transitions from: %i[idle paused], to: :connecting
end
event :go_live do
transitions from: %i[connecting reconnecting], to: :live
after { update!(started_at: Time.current) if started_at.nil? }
end
event :lose_connection do
transitions from: :live, to: :reconnecting
after { increment!(:disconnection_count) }
end
event :reconnect do
transitions from: :reconnecting, to: :live
end
event :pause do
transitions from: :live, to: :paused
end
event :resume do
transitions from: :paused, to: :live
end
event :finish do
transitions from: %i[live reconnecting paused connecting], to: :ended
after { close_session! }
end
event :fail do
transitions from: %i[connecting reconnecting idle], to: :error
end
end
def rtmp_ingest_url
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
"#{MatchLiveTv.mediamtx_rtmp_url}/#{mediamtx_path_name}"
end
def mediamtx_path_name
"live/match_#{id}"
end
def matchlivetv_platform?
platform == "matchlivetv"
end
def hls_playback_url
base = MatchLiveTv.hls_public_url.chomp("/")
"#{base}/#{mediamtx_path_name}/index.m3u8"
end
def watch_page_url
"#{MatchLiveTv.app_public_url.chomp('/')}/live/#{id}"
end
def terminal?
status.in?(%w[ended error])
end
def resumable?
status.in?(%w[connecting live reconnecting paused])
end
def end_stream!
return if terminal?
if may_finish?
finish!
else
update!(status: "ended")
record_ended_timestamps!
end
end
def stream_key
return @stream_key if instance_variable_defined?(:@stream_key)
return nil if stream_key_encrypted.blank?
stream_key_encryptor.decrypt_and_verify(stream_key_encrypted)
rescue ActiveSupport::MessageEncryptor::InvalidMessage
nil
end
def stream_key=(value)
@stream_key = value
self.stream_key_encrypted =
value.present? ? stream_key_encryptor.encrypt_and_sign(value) : nil
end
private
def stream_key_encryptor
key = ActiveSupport::KeyGenerator.new(Rails.application.secret_key_base)
.generate_key("stream_session_stream_key", 32)
ActiveSupport::MessageEncryptor.new(key)
end
def ensure_publish_token
self.publish_token ||= SecureRandom.urlsafe_base64(32)
end
def record_ended_timestamps!
now = Time.current
update!(ended_at: now) if ended_at.nil?
if started_at && total_duration_secs.to_i.zero?
update!(total_duration_secs: (now - started_at).to_i)
end
end
def close_session!
record_ended_timestamps!
end
end

View File

@@ -0,0 +1,28 @@
class Subscription < ApplicationRecord
STATUSES = %w[active trialing past_due canceled incomplete].freeze
ACTIVE_STATUSES = %w[active trialing].freeze
belongs_to :team
belongs_to :plan
validates :status, inclusion: { in: STATUSES }
validates :team_id, uniqueness: true
scope :active, -> { where(status: ACTIVE_STATUSES) }
def active?
status.in?(ACTIVE_STATUSES)
end
def premium?
active? && plan.premium?
end
def premium_full?
active? && plan.slug == "premium_full"
end
def free?
plan.slug == "free"
end
end

View File

@@ -0,0 +1,19 @@
class Team < ApplicationRecord
has_many :user_teams, dependent: :destroy
has_many :users, through: :user_teams
has_many :matches, dependent: :destroy
has_one :youtube_credential, dependent: :destroy
has_one :subscription, dependent: :destroy
has_many :recordings, dependent: :destroy
has_many :team_invitations, dependent: :destroy
validates :name, presence: true
def entitlements
@entitlements ||= Teams::Entitlements.new(self)
end
def owner
user_teams.find_by(role: "owner")&.user
end
end

View File

@@ -0,0 +1,28 @@
class TeamInvitation < ApplicationRecord
STAFF_KINDS = %w[transmission regia].freeze
belongs_to :team
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :token_digest, presence: true, uniqueness: true
validates :role, inclusion: { in: UserTeam::ROLES }
validates :staff_kind, inclusion: { in: STAFF_KINDS }
scope :pending, -> { where(accepted_at: nil).where("expires_at > ?", Time.current) }
def self.generate_token
SecureRandom.urlsafe_base64(32)
end
def accept!(user)
ut = UserTeam.find_or_initialize_by(user: user, team: team)
ut.role = role
ut.staff_kind = staff_kind
ut.save!
update!(accepted_at: Time.current)
end
def expired?
expires_at.past?
end
end

View File

@@ -0,0 +1,17 @@
class User < ApplicationRecord
ROLES = %w[admin coach parent volunteer].freeze
has_secure_password
has_many :user_teams, dependent: :destroy
has_many :teams, through: :user_teams
has_many :stream_sessions, dependent: :nullify
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :name, presence: true
validates :role, inclusion: { in: ROLES }
def admin?
role == "admin"
end
end

View File

@@ -0,0 +1,10 @@
class UserTeam < ApplicationRecord
ROLES = %w[owner member].freeze
belongs_to :user
belongs_to :team
validates :role, inclusion: { in: ROLES }
validates :staff_kind, inclusion: { in: TeamInvitation::STAFF_KINDS }, allow_nil: true
validates :user_id, uniqueness: { scope: :team_id }
end

View File

@@ -0,0 +1,22 @@
class YoutubeCredential < ApplicationRecord
belongs_to :team
attr_encrypted :access_token,
key: :encryption_key,
attribute: "access_token_encrypted",
mode: :single_iv_salt
attr_encrypted :refresh_token,
key: :encryption_key,
attribute: "refresh_token_encrypted",
mode: :single_iv_salt
def expired?
expires_at.present? && expires_at < Time.current
end
private
def encryption_key
Rails.application.secret_key_base[0, 32]
end
end

View File

@@ -0,0 +1,12 @@
module Billing
class AssignPlan
def self.call(team:, plan_slug:, status: "active", stripe_attrs: {})
plan = Plan[plan_slug]
sub = team.subscription || team.build_subscription
attrs = { plan: plan, status: status }.merge(stripe_attrs.symbolize_keys)
sub.assign_attributes(attrs)
sub.save!
sub
end
end
end

View File

@@ -0,0 +1,79 @@
module Billing
module Stripe
class CheckoutSession
VALID_PLANS = %w[premium_light premium_full].freeze
def initialize(team:, user:, plan_slug: "premium_light")
@team = team
@user = user
@plan_slug = plan_slug.to_s
end
def url
raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled?
raise "Piano non valido" unless VALID_PLANS.include?(@plan_slug)
plan = Plan[@plan_slug]
price_id = plan.stripe_price_id.presence || stripe_price_id_for(@plan_slug)
raise "Price ID Stripe mancante per #{@plan_slug}" if price_id.blank?
customer_id = ensure_customer_id!
session = ::Stripe::Checkout::Session.create(
mode: "subscription",
customer: customer_id,
line_items: [{ price: price_id, quantity: 1 }],
success_url: success_url,
cancel_url: cancel_url,
metadata: { team_id: @team.id, user_id: @user.id, plan_slug: @plan_slug },
subscription_data: { metadata: { team_id: @team.id, plan_slug: @plan_slug } }
)
session.url
end
def portal_url
raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled?
customer_id = ensure_customer_id!
session = ::Stripe::BillingPortal::Session.create(
customer: customer_id,
return_url: dashboard_url
)
session.url
end
private
def stripe_price_id_for(slug)
case slug
when "premium_light" then MatchLiveTv.stripe_premium_light_price_id
when "premium_full" then MatchLiveTv.stripe_premium_full_price_id
end
end
def ensure_customer_id!
sub = @team.subscription || @team.build_subscription(plan: Plan["free"], status: "active")
return sub.stripe_customer_id if sub.stripe_customer_id.present?
customer = ::Stripe::Customer.create(
email: @user.email,
name: @team.name,
metadata: { team_id: @team.id }
)
sub.update!(stripe_customer_id: customer.id)
customer.id
end
def success_url
"#{dashboard_url}?checkout=success"
end
def cancel_url
"#{dashboard_url}?checkout=canceled"
end
def dashboard_url
"#{MatchLiveTv.app_public_url.chomp('/')}/teams/#{@team.id}/dashboard"
end
end
end
end

View File

@@ -0,0 +1,131 @@
module Billing
module Stripe
class WebhookHandler
def self.call(event)
new(event).call
end
def initialize(event)
@event = event
end
def call
case @event.type
when "checkout.session.completed"
handle_checkout_completed(@event.data.object)
when "customer.subscription.updated", "customer.subscription.created"
sync_subscription(@event.data.object)
when "customer.subscription.deleted"
downgrade_to_free(@event.data.object)
when "invoice.payment_failed"
mark_past_due(@event.data.object)
end
end
private
def handle_checkout_completed(session)
team_id = metadata_team_id(session)
return if team_id.blank?
team = Team.find_by(id: team_id)
return unless team
sub = team.subscription || team.build_subscription
sub.update!(stripe_customer_id: session.customer) if session.customer.present?
if session.subscription.present?
stripe_sub = ::Stripe::Subscription.retrieve(session.subscription)
plan_slug = metadata_plan_slug(session) || "premium_light"
plan_slug = "premium_full" if plan_slug == "premium"
sync_subscription(stripe_sub, team: team, plan_slug: plan_slug)
end
end
def sync_subscription(stripe_sub, team: nil, plan_slug: nil)
team ||= Team.joins(:subscription)
.find_by(subscriptions: { stripe_subscription_id: stripe_sub.id })
team ||= Team.find_by(id: metadata_team_id(stripe_sub))
return unless team
plan_slug ||= metadata_plan_slug(stripe_sub) || "premium_light"
plan_slug = "premium_full" if plan_slug == "premium"
plan = Plan[plan_slug]
status = map_status(stripe_sub.status)
period_start = unix_time(stripe_sub.current_period_start)
period_end = unix_time(stripe_sub.current_period_end)
Billing::AssignPlan.call(
team: team,
plan_slug: plan.slug,
status: status,
stripe_attrs: {
stripe_customer_id: stripe_sub.customer,
stripe_subscription_id: stripe_sub.id,
current_period_start: period_start,
current_period_end: period_end,
cancel_at_period_end: stripe_sub.cancel_at_period_end
}
)
end
def downgrade_to_free(stripe_sub)
team = Team.joins(:subscription)
.find_by(subscriptions: { stripe_subscription_id: stripe_sub.id })
return unless team
Billing::AssignPlan.call(
team: team,
plan_slug: "free",
status: "active",
stripe_attrs: {
stripe_subscription_id: nil,
current_period_start: nil,
current_period_end: nil,
cancel_at_period_end: false
}
)
end
def mark_past_due(invoice)
return if invoice.subscription.blank?
team = Team.joins(:subscription)
.find_by(subscriptions: { stripe_subscription_id: invoice.subscription })
return unless team&.subscription
team.subscription.update!(status: "past_due")
end
def unix_time(value)
return nil if value.blank?
Time.zone.at(value.to_i)
end
def metadata_plan_slug(obj)
meta = obj.metadata
return nil if meta.blank?
meta["plan_slug"] || meta[:plan_slug] || (meta.respond_to?(:plan_slug) ? meta.plan_slug : nil)
end
def metadata_team_id(obj)
meta = obj.metadata
return nil if meta.blank?
meta["team_id"] || meta[:team_id] || (meta.respond_to?(:team_id) ? meta.team_id : nil)
end
def map_status(stripe_status)
case stripe_status
when "trialing" then "trialing"
when "active" then "active"
when "past_due", "unpaid" then "past_due"
when "canceled", "incomplete_expired" then "canceled"
else "incomplete"
end
end
end
end
end

View File

@@ -0,0 +1,101 @@
require "set"
module Mediamtx
class Client
class Error < StandardError; end
def initialize(base_url: MatchLiveTv.mediamtx_api_url)
@conn = Faraday.new(url: base_url) do |f|
f.request :json
f.response :json
f.adapter Faraday.default_adapter
end
end
def create_path(session)
path = session.mediamtx_path_name
record = session.match.team.entitlements.recording_enabled_for_mediamtx?
body = {
source: "publisher",
overridePublisher: true,
record: record,
recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S",
recordSegmentDuration: "60s"
}
unless session.matchlivetv_platform?
body[:runOnReady] = run_on_ready_script(session)
body[:runOnReadyRestart] = true
body[:runOnNotReady] = webhook_curl(session, "disconnect")
end
response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
unless response.success?
err = response.body.is_a?(Hash) ? response.body["error"] : response.body
raise Error, "MediaMTX path create failed: #{response.status} #{err}"
end
true
end
def delete_path(session)
delete_path_name(session.mediamtx_path_name)
end
def delete_path_name(path_name)
@conn.post("/v3/config/paths/delete/#{CGI.escape(path_name)}")
end
def list_paths
response = @conn.get("/v3/paths/list")
return [] unless response.success?
body = response.body
body.is_a?(Hash) ? (body["items"] || []) : []
rescue Error, Faraday::Error
[]
end
def online_path_names
Set.new(list_paths.filter_map { |item| item["name"] if item["online"] })
end
private
def publish_webhook(session)
webhook_curl(session, "connect")
end
def disconnect_webhook(session)
webhook_curl(session, "disconnect")
end
def webhook_curl(session, event)
secret = MatchLiveTv.mediamtx_webhook_secret
rails = ENV.fetch("RAILS_WEBHOOK_URL", "http://rails:3000")
payload = %({"session_id":"#{session.id}"})
# MediaMTX image non include curl; wget è disponibile nell'immagine ufficiale
<<~SCRIPT.squish
wget -q -O- --post-data='#{payload}' --header='Content-Type: application/json'
--header="X-MediaMTX-Signature: $(printf '%s' '#{payload}' | openssl dgst -sha256 -hmac '#{secret}' | cut -d' ' -f2)"
#{rails}/webhooks/mediamtx/#{event}
SCRIPT
end
def run_on_ready_script(session)
connect = webhook_curl(session, "connect")
return connect if session.matchlivetv_platform?
"#{connect} & #{relay_script(session)}"
end
def relay_script(session)
return "echo 'no youtube key'" if session.stream_key.blank?
return "echo 'youtube relay skipped'" unless session.platform == "youtube"
key = session.stream_key
path = session.mediamtx_path_name
<<~SCRIPT.squish
ffmpeg -re -i rtmp://127.0.0.1:1935/#{path} -c copy -f flv
rtmp://a.rtmp.youtube.com/live2/#{key} 2>/var/log/ffmpeg-#{path}.log
SCRIPT
end
end
end

View File

@@ -0,0 +1,25 @@
module Recordings
class IndexSession
def initialize(session)
@session = session
end
def call
team = @session.match.team
return unless team.entitlements.can_access_recordings?
retention_days = team.entitlements.recording_retention_days
expires_at = retention_days.positive? ? retention_days.days.from_now : nil
recording = Recording.find_or_initialize_by(stream_session: @session)
recording.assign_attributes(
team: team,
status: "ready",
storage_path: @session.mediamtx_path_name,
expires_at: expires_at
)
recording.save!
recording
end
end
end

View File

@@ -0,0 +1,61 @@
module Sessions
class Create
def initialize(user:, match:, params:)
@user = user
@match = match
@params = params
end
def call
platform = @params[:platform].presence || "matchlivetv"
ent = @match.team.entitlements
ent.assert_can_stream_on!(platform)
ent.assert_concurrent_stream!
session = StreamSession.new(
match: @match,
user: @user,
platform: platform,
privacy_status: @params[:privacy_status] || "unlisted",
quality_preset: @params[:quality_preset] || "720p_30_2.5mbps",
target_bitrate: @params[:target_bitrate] || 2_500_000,
target_fps: @params[:target_fps] || 30,
status: "idle"
)
if session.platform == "youtube"
attach_youtube_broadcast!(session)
end
StreamSession.transaction do
session.save!
session.create_score_state!
Mediamtx::Client.new.create_path(session)
log_event(session, "pairing", { created: true, platform: session.platform })
end
session
end
private
def attach_youtube_broadcast!(session)
yt = Youtube::BroadcastService.new(session.match.team)
title = "#{session.match.team.name} vs #{session.match.opponent_name}"
broadcast = yt.create_broadcast!(
title: title,
privacy_status: session.privacy_status,
scheduled_at: session.match.scheduled_at
)
session.youtube_broadcast_id = broadcast[:broadcast_id]
session.youtube_stream_id = broadcast[:stream_id]
session.stream_key = broadcast[:stream_key]
session.rtmp_url = broadcast[:rtmp_url]
end
def log_event(session, type, metadata)
session.stream_events.create!(event_type: type, metadata: metadata, occurred_at: Time.current)
end
end
end

View File

@@ -0,0 +1,20 @@
module Sessions
class Pause
def initialize(session)
@session = session
end
def call
@session.pause! if @session.may_pause?
log_event("paused")
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
@session
end
private
def log_event(type)
@session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: {})
end
end
end

View File

@@ -0,0 +1,21 @@
module Sessions
class Start
def initialize(session)
@session = session
end
def call
@session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session)
@session.begin_connect! if @session.may_begin_connect?
@session.update!(status: "connecting") unless @session.connecting?
broadcast_status("connecting")
@session
end
private
def broadcast_status(status)
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "starting", status: status })
end
end
end

View File

@@ -0,0 +1,30 @@
module Sessions
class Stop
def initialize(session)
@session = session
end
def call
cancel_timeout_job
@session.end_stream!
Youtube::BroadcastService.new(@session.match.team).complete_broadcast!(@session.youtube_broadcast_id)
Recordings::IndexSession.new(@session).call
Mediamtx::Client.new.delete_path(@session)
log_event("ended")
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" })
@session
end
private
def cancel_timeout_job
return if @session.timeout_job_id.blank?
Sidekiq::ScheduledSet.new.find_job(@session.timeout_job_id)&.delete
end
def log_event(type)
@session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: {})
end
end
end

View File

@@ -0,0 +1,11 @@
module Teams
class EntitlementError < StandardError
attr_reader :code, :billing_url
def initialize(message, code: "premium_required", billing_url: nil)
super(message)
@code = code
@billing_url = billing_url
end
end
end

View File

@@ -0,0 +1,239 @@
module Teams
class Entitlements
ACTIVE_STREAM_STATUSES = %w[idle connecting live reconnecting paused].freeze
def initialize(team)
@team = team
end
def subscription
@subscription ||= @team.subscription || ensure_free_subscription!
end
def plan
subscription.plan
end
def active?
subscription.active?
end
def premium_active?
subscription.premium?
end
def premium_full?
subscription.premium_full?
end
def youtube_enabled?
active? && plan.youtube_enabled?
end
def phone_download_enabled?
active? && plan.phone_download_enabled?
end
def max_staff_transmission
plan.max_staff_transmission
end
def max_staff_regia
plan.max_staff_regia
end
def max_staff
plan.max_staff
end
def staff_count
staff_count_for("transmission") + staff_count_for("regia")
end
def staff_count_for(kind)
members_count_for(kind) + pending_invitations_count_for(kind)
end
def members_count
@team.user_teams.where(role: "member").count
end
def members_count_for(kind)
@team.user_teams.where(role: "member", staff_kind: kind).count
end
def pending_invitations_count
@team.team_invitations.pending.count
end
def pending_invitations_count_for(kind)
@team.team_invitations.pending.where(staff_kind: kind).count
end
def concurrent_streams_limit
plan.concurrent_streams_limit
end
def concurrent_streams_used
StreamSession.joins(:match)
.where(matches: { team_id: @team.id })
.where(status: ACTIVE_STREAM_STATUSES)
.count
end
def concurrent_streams_limit_reached?
limit = concurrent_streams_limit
return false if limit.nil?
concurrent_streams_used >= limit
end
def can_stream_on?(platform)
return false unless active?
platform = platform.to_s
return false unless plan.allows_platform?(platform)
true
end
def assert_can_stream_on!(platform)
platform = platform.to_s
unless active?
raise EntitlementError.new(
"Abbonamento non attivo per questa squadra",
code: "subscription_inactive",
billing_url: billing_url
)
end
if platform == "youtube" && !youtube_enabled?
raise EntitlementError.new(
"YouTube richiede un piano Premium",
code: "premium_required",
billing_url: billing_url
)
end
if platform == "youtube" && premium_full? == false && plan.youtube_mode != "matchlivetv_light"
raise EntitlementError.new(
"Il canale YouTube della società richiede Premium Full",
code: "premium_full_required",
billing_url: billing_url
)
end
return if plan.allows_platform?(platform)
raise EntitlementError.new("Piattaforma non consentita", code: "platform_denied", billing_url: billing_url)
end
def assert_concurrent_stream!(excluding_session: nil)
return unless active?
used = concurrent_streams_used
used -= 1 if excluding_session && ACTIVE_STREAM_STATUSES.include?(excluding_session.status)
limit = concurrent_streams_limit
return if limit.nil?
return if used < limit
raise EntitlementError.new(
"Limite dirette concorrenti raggiunto (#{limit}). Chiudi una diretta o passa a un piano superiore.",
code: "concurrent_limit_reached",
billing_url: billing_url
)
end
def assert_can_invite!(staff_kind: "transmission")
return unless active?
kind = staff_kind.to_s
unless TeamInvitation::STAFF_KINDS.include?(kind)
raise EntitlementError.new("Ruolo staff non valido", code: "invalid_staff_kind")
end
limit = staff_limit_for(kind)
return if limit.nil?
used = staff_count_for(kind)
return if used < limit
label = kind == "regia" ? "regia" : "trasmissione"
raise EntitlementError.new(
"Limite staff #{label} raggiunto (#{limit} all'anno). Passa a un piano superiore.",
code: "staff_limit_reached",
billing_url: billing_url
)
end
def staff_limit_for(kind)
kind == "regia" ? max_staff_regia : max_staff_transmission
end
def assert_can_connect_youtube!
unless premium_full?
raise EntitlementError.new(
"Collegamento al canale YouTube della società richiede Premium Full",
code: "premium_full_required",
billing_url: billing_url
)
end
assert_can_stream_on!("youtube")
end
def can_access_recordings?
active? && plan.recordings_enabled?
end
def recording_enabled_for_mediamtx?
can_access_recordings?
end
def recording_retention_days
plan.recording_days
end
def billing_url
"#{MatchLiveTv.app_public_url.chomp('/')}/teams/#{@team.id}/billing"
end
def staff_manage_url
"#{MatchLiveTv.app_public_url.chomp('/')}/teams/#{@team.id}/dashboard"
end
def as_json
{
plan_slug: plan.slug,
plan_name: plan.name,
premium_active: premium_active?,
premium_full: premium_full?,
subscription_status: subscription.status,
features: plan.features,
billing_url: billing_url,
staff_manage_url: staff_manage_url,
max_staff: max_staff,
max_staff_transmission: max_staff_transmission,
max_staff_regia: max_staff_regia,
staff_used: staff_count,
staff_transmission_used: staff_count_for("transmission"),
staff_regia_used: staff_count_for("regia"),
concurrent_streams_used: concurrent_streams_used,
concurrent_streams_limit: concurrent_streams_limit,
recordings_enabled: can_access_recordings?,
recording_retention_days: recording_retention_days,
phone_download_enabled: phone_download_enabled?,
youtube_enabled: youtube_enabled?,
youtube_mode: plan.youtube_mode
}
end
private
def ensure_free_subscription!
Billing::AssignPlan.call(team: @team, plan_slug: "free")
@team.reload.subscription
end
end
end

View File

@@ -0,0 +1,94 @@
module Webhooks
class MediamtxHandler
def initialize(event:, session_id:, metadata: {})
@event = event
@session_id = session_id
@metadata = metadata
end
def call
session = StreamSession.find_by(id: @session_id)
return unless session
case @event
when "connect"
handle_connect(session)
when "disconnect"
handle_disconnect(session)
when "ready"
handle_ready(session)
end
end
private
def handle_connect(session)
return if session.live? && session.stream_events.where(event_type: "connected").exists?
if session.reconnecting?
session.reconnect! if session.may_reconnect?
cancel_timeout(session)
log(session, "reconnected")
broadcast(session, "reconnected")
elsif session.connecting? || session.idle?
session.go_live! if session.may_go_live?
log(session, "connected")
broadcast(session, "connected")
end
end
def handle_disconnect(session)
return unless session.live? || session.connecting?
if session.connecting?
session.update!(status: "reconnecting")
elsif session.may_lose_connection?
session.lose_connection!
end
log(session, "disconnected", @metadata)
broadcast(session, "disconnected")
schedule_timeout(session)
end
def handle_ready(session)
log(session, "connected", { ready: true })
end
def schedule_timeout(session)
job = DisconnectionTimeoutJob.perform_in(
MatchLiveTv.reconnect_timeout_seconds.seconds,
session.id
)
session.update!(timeout_job_id: job)
end
def cancel_timeout(session)
return if session.timeout_job_id.blank?
Sidekiq::ScheduledSet.new.find_job(session.timeout_job_id)&.delete
session.update!(timeout_job_id: nil)
end
def log(session, type, meta = {})
return if duplicate_event?(session, type, meta)
session.stream_events.create!(
event_type: type,
metadata: meta.merge(source: "mediamtx"),
occurred_at: Time.current
)
end
def duplicate_event?(session, type, meta)
session.stream_events
.where(event_type: type)
.where("occurred_at > ?", 2.seconds.ago)
.where(metadata: meta.merge(source: "mediamtx"))
.exists?
end
def broadcast(session, event)
SessionChannel.broadcast_message(session, { type: "stream_event", event: event })
end
end
end

View File

@@ -0,0 +1,14 @@
module Webhooks
class Signature
def self.valid?(payload, signature)
return false if signature.blank?
expected = OpenSSL::HMAC.hexdigest(
"SHA256",
MatchLiveTv.mediamtx_webhook_secret,
payload
)
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
end
end
end

View File

@@ -0,0 +1,89 @@
module Youtube
class BroadcastService
class Error < StandardError; end
def initialize(team)
@team = team
@credential = team.youtube_credential
end
def create_broadcast!(title:, privacy_status: "unlisted", scheduled_at: nil)
return mock_broadcast if @credential.blank? || missing_oauth_config?
client = authorized_client
broadcast = Google::Apis::YoutubeV3::LiveBroadcast.new(
snippet: Google::Apis::YoutubeV3::LiveBroadcastSnippet.new(
title: title,
scheduled_start_time: scheduled_at&.iso8601
),
status: Google::Apis::YoutubeV3::LiveBroadcastStatus.new(
privacy_status: privacy_status,
self_declared_made_for_kids: false
),
content_details: Google::Apis::YoutubeV3::LiveBroadcastContentDetails.new(
enable_auto_start: true,
enable_auto_stop: true
)
)
result = client.insert_live_broadcast("snippet,status,contentDetails", broadcast)
stream = Google::Apis::YoutubeV3::LiveStream.new(
snippet: Google::Apis::YoutubeV3::LiveStreamSnippet.new(title: "#{title} stream"),
cdn: Google::Apis::YoutubeV3::CdnSettings.new(
frame_rate: "30fps",
ingestion_type: "rtmp",
resolution: "720p"
)
)
stream_result = client.insert_live_stream("snippet,cdn", stream)
client.bind_live_broadcast(result.id, "id,contentDetails", stream_result.id)
{
broadcast_id: result.id,
stream_id: stream_result.id,
stream_key: stream_result.cdn.ingestion_info.stream_name,
rtmp_url: stream_result.cdn.ingestion_info.ingestion_address
}
rescue Google::Apis::Error => e
raise Error, e.message
end
def complete_broadcast!(broadcast_id)
return if @credential.blank? || missing_oauth_config?
client = authorized_client
client.transition_live_broadcast(broadcast_id, "complete")
rescue Google::Apis::Error
nil
end
def viewer_count(broadcast_id)
return 0 if @credential.blank? || missing_oauth_config?
client = authorized_client
resp = client.list_live_broadcasts("statistics", id: broadcast_id)
resp.items&.first&.statistics&.concurrent_viewers.to_i
rescue Google::Apis::Error
0
end
private
def mock_broadcast
{
broadcast_id: "mock_#{SecureRandom.hex(8)}",
stream_id: "mock_stream",
stream_key: ENV.fetch("YOUTUBE_MOCK_STREAM_KEY", "mock-stream-key"),
rtmp_url: "rtmp://a.rtmp.youtube.com/live2"
}
end
def missing_oauth_config?
ENV["YOUTUBE_CLIENT_ID"].blank?
end
def authorized_client
OauthRefresh.new(@credential).apply!(Google::Apis::YoutubeV3::YouTubeService.new)
end
end
end

View File

@@ -0,0 +1,25 @@
module Youtube
class OauthExchange
TOKEN_URL = "https://oauth2.googleapis.com/token".freeze
def self.call(code)
response = Faraday.post(TOKEN_URL, {
code: code,
client_id: ENV["YOUTUBE_CLIENT_ID"],
client_secret: ENV["YOUTUBE_CLIENT_SECRET"],
redirect_uri: ENV["YOUTUBE_REDIRECT_URI"],
grant_type: "authorization_code"
})
data = JSON.parse(response.body)
raise BroadcastService::Error, data["error_description"] if data["error"]
{
access_token: data["access_token"],
refresh_token: data["refresh_token"],
expires_in: data["expires_in"].to_i,
channel_id: nil,
channel_title: nil
}
end
end
end

View File

@@ -0,0 +1,31 @@
module Youtube
class OauthRefresh
TOKEN_URL = "https://oauth2.googleapis.com/token".freeze
def initialize(credential)
@credential = credential
end
def apply!(service)
refresh! if @credential.expired?
service.authorization = @credential.access_token
service
end
def refresh!
response = Faraday.post(TOKEN_URL, {
client_id: ENV["YOUTUBE_CLIENT_ID"],
client_secret: ENV["YOUTUBE_CLIENT_SECRET"],
refresh_token: @credential.refresh_token,
grant_type: "refresh_token"
})
data = JSON.parse(response.body)
raise Youtube::BroadcastService::Error, data["error"] if data["error"]
@credential.update!(
access_token: data["access_token"],
expires_at: Time.current + data["expires_in"].to_i.seconds
)
end
end
end

View File

@@ -0,0 +1,22 @@
module Youtube
class OauthUrl
AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth".freeze
SCOPES = [
"https://www.googleapis.com/auth/youtube",
"https://www.googleapis.com/auth/youtube.force-ssl"
].join(" ").freeze
def self.build(team_id:, redirect_uri:)
params = {
client_id: ENV.fetch("YOUTUBE_CLIENT_ID", "not_configured"),
redirect_uri: redirect_uri,
response_type: "code",
scope: SCOPES,
access_type: "offline",
prompt: "consent",
state: team_id
}
"#{AUTH_URL}?#{URI.encode_www_form(params)}"
end
end
end

View File

@@ -0,0 +1,25 @@
<h2>Sessioni attive</h2>
<% if @active_sessions.any? %>
<table>
<thead><tr><th>Match</th><th>Status</th><th>Iniziata</th><th></th></tr></thead>
<tbody>
<% @active_sessions.each do |s| %>
<tr>
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
<td><span class="badge live"><%= s.status %></span></td>
<td><%= s.started_at %></td>
<td><%= link_to "Dettaglio", admin_session_path(s) %></td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p>Nessuna sessione attiva.</p>
<% end %>
<h2>Squadre</h2>
<ul>
<% @teams.each do |t| %>
<li><%= link_to t.name, admin_team_path(t) %> — <%= t.matches.count %> partite</li>
<% end %>
</ul>

View File

@@ -0,0 +1,14 @@
<h2>Stream Sessions</h2>
<table>
<thead><tr><th>Match</th><th>Status</th><th>Disconnects</th><th></th></tr></thead>
<tbody>
<% @sessions.each do |s| %>
<tr>
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
<td><span class="badge <%= s.live? ? 'live' : 'ended' %>"><%= s.status %></span></td>
<td><%= s.disconnection_count %></td>
<td><%= link_to "Dettaglio", admin_session_path(s) %></td>
</tr>
<% end %>
</tbody>
</table>

View File

@@ -0,0 +1,21 @@
<h2>Session <%= @session.id %></h2>
<p>Status: <strong><%= @session.status %></strong></p>
<p>Match: <%= @session.match.team.name %> vs <%= @session.match.opponent_name %></p>
<% if @session.youtube_broadcast_id %>
<p>YouTube: <a href="https://studio.youtube.com/video/<%= @session.youtube_broadcast_id %>/livestreaming" target="_blank">Broadcast</a></p>
<% end %>
<p>RTMP ingest: <code><%= @session.rtmp_ingest_url %></code></p>
<h3>Eventi</h3>
<table>
<thead><tr><th>Tipo</th><th>Quando</th><th>Meta</th></tr></thead>
<tbody>
<% @events.each do |e| %>
<tr>
<td><%= e.event_type %></td>
<td><%= e.occurred_at %></td>
<td><%= e.metadata.to_json %></td>
</tr>
<% end %>
</tbody>
</table>

View File

@@ -0,0 +1,13 @@
<h2>Teams</h2>
<table>
<thead><tr><th>Nome</th><th>Sport</th><th>YouTube</th></tr></thead>
<tbody>
<% @teams.each do |t| %>
<tr>
<td><%= link_to t.name, admin_team_path(t) %></td>
<td><%= t.sport %></td>
<td><%= t.youtube_credential.present? ? "✓" : "—" %></td>
</tr>
<% end %>
</tbody>
</table>

View File

@@ -0,0 +1,10 @@
<h2><%= @team.name %></h2>
<p>Sport: <%= @team.sport %></p>
<p>YouTube: <%= @team.youtube_credential ? "Connesso" : link_to("Connetti", "/api/v1/teams/#{@team.id}/youtube/authorize") %></p>
<h3>Partite</h3>
<ul>
<% @matches.each do |m| %>
<li><%= m.opponent_name %> — <%= m.scheduled_at %></li>
<% end %>
</ul>

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<title>Match Live TV Admin</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root { --red: #FF2D2D; --bg: #0A0A0A; --card: #1E1E1E; --text: #fff; }
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 1rem 2rem; }
a { color: var(--red); }
header { border-bottom: 1px solid #333; padding-bottom: 1rem; margin-bottom: 2rem; }
h1 span { color: var(--red); }
table { width: 100%; border-collapse: collapse; background: var(--card); border-radius: 8px; overflow: hidden; }
th, td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid #333; }
.badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.8rem; }
.live { background: var(--red); }
.ended { background: #555; }
nav a { margin-right: 1rem; }
</style>
</head>
<body>
<header>
<h1>MATCH <span>LIVE</span> TV — Admin</h1>
<nav>
<%= link_to "Dashboard", admin_root_path %>
<%= link_to "Teams", admin_teams_path %>
<%= link_to "Sessions", admin_sessions_path %>
</nav>
</header>
<%= yield %>
</body>
</html>

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<%= yield %>
</body>
</html>

View File

@@ -0,0 +1 @@
<%= yield %>

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
<%= render "shared/meta_tags" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=14">
</head>
<body>
<%= render "shared/marketing_nav" %>
<main>
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
<%= yield %>
</main>
<%= render "shared/marketing_footer" %>
</body>
</html>

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
<%= render "shared/meta_tags" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=14">
<link rel="stylesheet" href="/live.css">
<%= yield :head %>
</head>
<body>
<%= render "shared/marketing_nav" %>
<main class="live-main">
<%= yield %>
</main>
<%= render "shared/marketing_footer" %>
</body>
</html>

View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; background: #0f0f12; color: #f5f5f5; line-height: 1.5; }
a { color: #ff6b6b; }
.header { border-bottom: 1px solid #2a2a32; background: #14141a; padding: 14px 0; margin-bottom: 24px; }
.wrap { max-width: 880px; margin: 0 auto; padding: 0 16px 40px; }
.header .wrap { display: flex; justify-content: space-between; align-items: center; }
.brand { font-weight: 800; color: #fff; text-decoration: none; }
.brand span { color: #e53935; }
.flash { padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; }
.flash.notice { background: #1b3d2a; color: #a5f0b8; }
.flash.alert { background: #3d1b1b; color: #ffb4b4; }
.card { background: #1a1a22; border: 1px solid #2a2a32; border-radius: 12px; padding: 20px; margin-bottom: 16px; }
.btn { display: inline-block; padding: 12px 20px; border-radius: 8px; font-weight: 700; text-decoration: none; border: none; cursor: pointer; }
.btn-primary { background: #e53935; color: #fff; }
.btn-secondary { background: #2a2a32; color: #fff; }
label { display: block; margin-bottom: 6px; font-size: 0.9rem; color: #aaa; }
input, select { width: 100%; padding: 10px 12px; margin-bottom: 14px; border-radius: 8px; border: 1px solid #333; background: #0f0f12; color: #fff; }
.plans { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
.plan-premium { border-color: #e53935; }
ul.features { padding-left: 18px; color: #ccc; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #2a2a32; }
</style>
</head>
<body>
<header class="header">
<div class="wrap">
<%= link_to public_pricing_path, class: "brand" do %>Match <span>Live TV</span><% end %>
<nav>
<%= link_to "Prezzi", public_pricing_path %>
<% if logged_in? %>
<% if current_user.teams.any? %>
· <%= link_to "Dashboard", public_team_dashboard_path(current_user.teams.first) %>
<% end %>
· <%= button_to "Esci", public_logout_path, method: :delete, form: { style: "display:inline" }, class: "btn btn-secondary", style: "padding:6px 12px;font-size:0.85rem" %>
<% else %>
· <%= link_to "Accedi", public_login_path %>
· <%= link_to "Registrati", public_signup_path, class: "btn btn-primary", style: "padding:6px 12px;font-size:0.85rem" %>
<% end %>
</nav>
</div>
</header>
<main class="wrap">
<% if flash[:notice] %><div class="flash notice"><%= flash[:notice] %></div><% end %>
<% if flash[:alert] %><div class="flash alert"><%= flash[:alert] %></div><% end %>
<%= yield %>
</main>
</body>
</html>

View File

@@ -0,0 +1,12 @@
<% content_for :title, "Invito squadra" %>
<div class="card" style="max-width:480px">
<h1>Accesso staff — <%= @invitation.team.name %></h1>
<p>Sei stato aggiunto allo staff come <strong><%= @invitation.staff_kind == "regia" ? "Regia" : "Trasmissione" %></strong> (<%= @invitation.email %>).</p>
<% if logged_in? %>
<%= button_to "Accetta invito", public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %>
<% else %>
<p><%= link_to "Accedi", public_login_path %> o <%= link_to "registrati", public_signup_path %> con <%= @invitation.email %>.</p>
<%= button_to "Accetta (se già loggato)", public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %>
<% end %>
</div>

View File

@@ -0,0 +1,76 @@
<% content_for :title do %>Dirette in corso — Match Live TV<% end %>
<div class="wrap">
<h1>Dirette in corso</h1>
<p class="results-hint">
Cerca per nome squadra o avversario, poi apri la diretta per guardare lo stream.
</p>
<%= form_with url: public_live_index_path, method: :get, local: true, class: "search-form" do %>
<input
type="search"
name="q"
value="<%= @query %>"
placeholder="Es. Tigers, Eagles, nome squadra…"
aria-label="Cerca squadra"
autocomplete="off"
/>
<button type="submit" class="btn btn-primary">Cerca</button>
<% if @query.present? %>
<%= link_to "Azzera", public_live_index_path, class: "btn btn-secondary" %>
<% end %>
<% end %>
<% if @query.present? %>
<p class="results-hint">
Risultati per «<%= h @query %>»: <%= @sessions.size %> diretta<%= @sessions.size == 1 ? "" : "e" %>
</p>
<% end %>
<% if @sessions.any? %>
<div class="live-grid">
<% @sessions.each do |session| %>
<% match = session.match %>
<% on_air = @online_paths.include?(session.mediamtx_path_name) %>
<article class="live-card">
<h3><%= match.team.name %> vs <%= match.opponent_name %></h3>
<p class="meta">
<% if match.location.present? %><%= match.location %> · <% end %>
<%= session.platform == "matchlivetv" ? "Match Live TV" : session.platform.capitalize %>
</p>
<% if session.score_state %>
<p class="card-score">
<span class="card-sets"><%= live_score_sets_label(session.score_state) %></span>
<% if live_score_partials_label(session.score_state).present? %>
<span class="card-partials">Parziali: <%= live_score_partials_label(session.score_state) %></span>
<% end %>
<span class="card-points"><%= session.score_state.home_points %> - <%= session.score_state.away_points %></span>
</p>
<% end %>
<div class="badges">
<% if on_air %>
<span class="badge badge-on-air">In onda</span>
<% elsif session.status == "live" %>
<span class="badge badge-live">Live</span>
<% elsif session.status == "reconnecting" %>
<span class="badge badge-connecting">Riconnessione</span>
<% else %>
<span class="badge badge-wait">In avvio</span>
<% end %>
</div>
<%= link_to "Guarda diretta →", public_live_path(session), class: "btn-watch" %>
</article>
<% end %>
</div>
<% else %>
<div class="empty-state">
<% if @query.present? %>
<p>Nessuna diretta attiva per «<%= h @query %>».</p>
<p>Prova un altro nome squadra o <%= link_to "mostra tutte le dirette", public_live_index_path %>.</p>
<% else %>
<p>Al momento non ci sono dirette attive.</p>
<p>Quando una squadra avvia lo streaming dallapp, la partita comparirà qui.</p>
<% end %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,227 @@
<% content_for :title do %><%= @match.team.name %> vs <%= @match.opponent_name %> — Match Live TV<% end %>
<% content_for :head do %>
<% unless @stream_closed %>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.7"></script>
<% end %>
<% end %>
<div class="wrap">
<%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %>
<h1><%= @match.team.name %> vs <%= @match.opponent_name %></h1>
<p>
<% if @stream_closed %>
<span id="status-badge" class="badge badge-ended">Diretta chiusa</span>
<% elsif @on_air %>
<span id="status-badge" class="badge badge-on-air">In onda</span>
<% else %>
<span id="status-badge" class="badge badge-wait">In attesa</span>
<% end %>
</p>
<div id="scoreboard" class="scoreboard">
<p id="sets-line" class="sets-line"><%= live_score_sets_label(@session.score_state) || "—" %></p>
<p id="partials-line" class="partials-line"<%= " hidden" unless live_score_partials_label(@session.score_state).present? %>>
Parziali: <%= live_score_partials_label(@session.score_state) %>
</p>
<p id="score-line" class="score"><%= live_score_points_label(@match, @session.score_state) %></p>
</div>
<% if @stream_closed %>
<div id="stream-ended" class="stream-ended" role="status" aria-live="polite">
<div class="stream-ended-icon" aria-hidden="true">📡</div>
<h2>Diretta terminata</h2>
<p>Lo streaming di questa partita è stato chiuso.</p>
<% if @session.ended_at %>
<p class="stream-ended-meta">Chiusa il <%= l(@session.ended_at.in_time_zone, format: :long) %></p>
<% end %>
<p class="stream-ended-hint">Il punteggio finale resta visibile sopra.</p>
<%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %>
</div>
<% else %>
<video id="player" controls playsinline muted autoplay></video>
<p id="offline-msg" style="display:none;color:#aaa;">La diretta non è ancora disponibile. Si aggiorna automaticamente quando torna in onda.</p>
<script>
const sessionId = "<%= @session.id %>";
const hlsUrl = "<%= j @session.hls_playback_url %>";
const video = document.getElementById("player");
const badge = document.getElementById("status-badge");
const setsLine = document.getElementById("sets-line");
const partialsLine = document.getElementById("partials-line");
const scoreLine = document.getElementById("score-line");
const offlineMsg = document.getElementById("offline-msg");
let hls = null;
let wasOnAir = <%= @on_air ? "true" : "false" %>;
let reloadTimer = null;
let stallPolls = 0;
let streamClosed = false;
function setBadge(status, live, onAir, closed) {
if (closed) {
badge.textContent = "Diretta chiusa";
badge.className = "badge badge-ended";
return;
}
if (onAir) {
badge.textContent = "In onda";
badge.className = "badge badge-on-air";
} else if (live) {
badge.textContent = "LIVE";
badge.className = "badge badge-live";
} else if (status === "reconnecting") {
badge.textContent = "Riconnessione";
badge.className = "badge badge-wait";
} else {
badge.textContent = status;
badge.className = "badge badge-wait";
}
}
function formatSets(score) {
if (!score) return "—";
return `Set ${score.current_set} · Set vinti ${score.home_sets}-${score.away_sets}`;
}
function formatPartials(score) {
const partials = score?.set_partials;
if (!partials?.length) return "";
return partials
.map((p) => `Set ${p.set} ${p.home}-${p.away}`)
.join(" · ");
}
function formatPoints(data) {
if (!data.score || !data.home_name || !data.away_name) return "—";
const s = data.score;
return `${data.home_name} ${s.home_points} - ${s.away_points} ${data.away_name}`;
}
function updateScoreboard(data) {
const score = data.score;
setsLine.textContent = formatSets(score);
const partials = formatPartials(score);
if (partials) {
partialsLine.textContent = `Parziali: ${partials}`;
partialsLine.hidden = false;
} else {
partialsLine.hidden = true;
}
scoreLine.textContent = formatPoints(data);
}
function showStreamEnded() {
if (streamClosed) return;
streamClosed = true;
destroyPlayer();
video.style.display = "none";
offlineMsg.style.display = "none";
const panel = document.createElement("div");
panel.id = "stream-ended";
panel.className = "stream-ended";
panel.setAttribute("role", "status");
panel.innerHTML = `
<div class="stream-ended-icon" aria-hidden="true">📡</div>
<h2>Diretta terminata</h2>
<p>Lo streaming di questa partita è stato chiuso.</p>
<p class="stream-ended-hint">Il punteggio finale resta visibile sopra.</p>
<a href="<%= public_live_index_path %>" class="btn btn-secondary stream-ended-btn">Tutte le dirette</a>
`;
video.parentNode.insertBefore(panel, video.nextSibling);
}
function hlsUrlFresh() {
const sep = hlsUrl.includes("?") ? "&" : "?";
return `${hlsUrl}${sep}_ts=${Date.now()}`;
}
function destroyPlayer() {
if (hls) {
hls.destroy();
hls = null;
}
video.removeAttribute("src");
video.load();
}
function startPlayer() {
destroyPlayer();
const url = hlsUrlFresh();
if (Hls.isSupported()) {
hls = new Hls({ lowLatencyMode: true, enableWorker: true });
hls.loadSource(url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
hls.on(Hls.Events.ERROR, (_, data) => {
if (data.fatal) {
scheduleReload(2500);
}
});
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = url;
video.addEventListener("loadedmetadata", () => video.play().catch(() => {}), { once: true });
}
}
function scheduleReload(delayMs) {
if (reloadTimer) return;
reloadTimer = setTimeout(() => {
reloadTimer = null;
pollStatus();
}, delayMs);
}
async function pollStatus() {
try {
const res = await fetch(`/live/${sessionId}/status.json`);
const data = await res.json();
setBadge(data.status, data.live, data.on_air, data.stream_closed);
updateScoreboard(data);
if (data.stream_closed) {
showStreamEnded();
return;
}
const onAir = !!data.on_air;
if (onAir) {
offlineMsg.style.display = "none";
if (!wasOnAir) {
startPlayer();
stallPolls = 0;
} else if (video.readyState < 2) {
stallPolls += 1;
if (stallPolls >= 2) {
startPlayer();
stallPolls = 0;
}
} else {
stallPolls = 0;
}
} else {
offlineMsg.style.display = "block";
destroyPlayer();
stallPolls = 0;
}
wasOnAir = onAir;
} catch (_) {
scheduleReload(5000);
}
}
if (wasOnAir) {
startPlayer();
} else {
offlineMsg.style.display = "block";
}
pollStatus();
setInterval(pollStatus, 3000);
</script>
<% end %>
</div>

View File

@@ -0,0 +1,63 @@
<% content_for :title, "Funzionalità — Match Live TV" %>
<% content_for :meta_description, "Camera, regia, punteggio live, gestione staff, archivio gare e YouTube con Premium Light e Full." %>
<div class="wrap">
<h1 style="margin-top:24px">Funzionalità</h1>
<p style="color:#aaa;max-width:640px">Tutto ciò che serve per trasmettere partite giovanili in modo professionale, senza complessità da broadcaster TV.</p>
<section class="section">
<h2>Per la società e lo staff</h2>
<div class="features-grid">
<div class="feature-card">
<h3>Staff con email</h3>
<p>Delega trasmissione e regia a coach e volontari: ognuno ha credenziali personali.</p>
</div>
<div class="feature-card">
<h3>Revoca accessi</h3>
<p>L'owner rimuove membri dello staff e annulla richieste pendenti dalla dashboard web.</p>
</div>
<div class="feature-card">
<h3>Limiti per piano</h3>
<p>Posti staff (trasmissione e regia) e partite concorrenti scalano con Free, Premium Light e Premium Full.</p>
</div>
</div>
</section>
<section class="section">
<h2>In campo e in diretta</h2>
<div class="features-grid">
<div class="feature-card">
<h3>Modalità camera</h3>
<p>Streaming RTMP resiliente: la diretta continua anche con reti instabili.</p>
</div>
<div class="feature-card">
<h3>Regia separata</h3>
<p>Secondo dispositivo per aggiornare il punteggio via QR pairing.</p>
</div>
<div class="feature-card">
<h3>Pagina pubblica /live</h3>
<p>Genitori e tifosi guardano dal browser senza installare nulla.</p>
</div>
<div class="feature-card">
<h3>Archivio replay</h3>
<p>Premium Light/Full: salvataggio server (30 o 90 giorni) e download su telefono.</p>
</div>
<div class="feature-card">
<h3>YouTube Match TV Light</h3>
<p>Premium Light: le dirette sono visibili anche sul canale YouTube Match TV Light.</p>
</div>
<div class="feature-card">
<h3>YouTube della società</h3>
<p>Premium Full: relay verso il canale YouTube del club.</p>
</div>
<div class="feature-card">
<h3>Sito della società</h3>
<p>Integrazione embed sul sito del club — in arrivo con Premium Full.</p>
</div>
</div>
</section>
<p style="text-align:center;margin:40px 0">
<%= link_to "Registra la squadra", public_signup_path, class: "btn btn-primary" %>
</p>
</div>

View File

@@ -0,0 +1,88 @@
<% content_for :title, "Match Live TV — Ogni partita, ogni evento, per chi non può esserci" %>
<% content_for :meta_description, "Trasmetti le partite dallo smartphone. Nonni, parenti e amici guardano dal browser, senza app. Se te la perdi, la ritrovi nell'archivio." %>
<section class="hero hero-split">
<div class="wrap hero-grid">
<div class="hero-content">
<p class="hero-live-badge"><span class="hero-live-dot" aria-hidden="true"></span> LIVE</p>
<h1>Ogni partita, ogni evento, per chi <span class="hero-accent">non può esserci.</span></h1>
<p class="tagline">
Trasmetti in diretta dallo smartphone. I nonni, i parenti lontani, gli amici —
guardano dal browser, senza installare nulla.
E se te la sei persa, la ritrovi nell'archivio.
</p>
<div class="hero-cta">
<%= link_to "Registra la tua squadra", public_signup_path, class: "btn btn-primary" %>
<%= link_to "Guarda le dirette", public_live_index_path, class: "btn btn-secondary" %>
</div>
<ul class="hero-features" aria-label="Vantaggi principali">
<li class="hero-feature-item">
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
<span class="hero-feature-label">Dirette stabili</span>
</li>
<li class="hero-feature-item">
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-cloud"></i></span>
<span class="hero-feature-label">Archivio sicuro</span>
</li>
<li class="hero-feature-item">
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-user-group"></i></span>
<span class="hero-feature-label">Condividi con chi vuoi</span>
</li>
<li class="hero-feature-item hero-feature-item--last">
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
<span class="hero-feature-label">Tutto dal tuo telefono</span>
</li>
</ul>
</div>
<div class="hero-visual">
<%= image_tag "/hero-devices.png", alt: "Smartphone su treppiede per la diretta e secondo telefono per il punteggio in palestra", class: "hero-devices-img", loading: "eager", fetchpriority: "high" %>
</div>
</div>
</section>
<section class="section wrap">
<h2>Come funziona</h2>
<div class="steps">
<div class="step step--visual">
<div class="step-visual">
<%= image_tag "/home-step-registra-squadra.png?v=1", alt: "Registrazione squadra: scudo, pallone e modulo con email, password e nome squadra", class: "step-visual-img", loading: "lazy" %>
</div>
<div class="step-num">01</div>
<h3>Registra la squadra</h3>
<p>La società si iscrive sul sito e sceglie il piano più adatto.</p>
</div>
<div class="step step--visual">
<div class="step-visual">
<%= image_tag "/home-step-invita-trasmette.png?v=1", alt: "Invito a trasmettere: smartphone con invio email e busta con accetta invito", class: "step-visual-img", loading: "lazy" %>
</div>
<div class="step-num">02</div>
<h3>Invita chi trasmette</h3>
<p>Coach e volontari ricevono un invito: ognuno accede con la propria email.</p>
</div>
<div class="step step--visual">
<div class="step-visual">
<%= image_tag "/home-step-vai-in-diretta.png?v=1", alt: "Vai in diretta: smartphone su treppiede, punteggio live e condivisione link", class: "step-visual-img", loading: "lazy" %>
</div>
<div class="step-num">03</div>
<h3>Vai in diretta</h3>
<p>Dal telefono si avvia la partita: punteggio aggiornato e link da condividere con le famiglie.</p>
</div>
<div class="step step--visual">
<div class="step-visual">
<%= image_tag "/home-step-tutti-guardano.png?v=2", alt: "Tutti guardano da casa: archivio partite, partita salvata e link per genitori e parenti", class: "step-visual-img", loading: "lazy" %>
</div>
<div class="step-num">04</div>
<h3>Tutti guardano da casa</h3>
<p>Genitori, nonni e parenti aprono il link nel browser. La partita resta anche in archivio.</p>
</div>
</div>
</section>
<section class="section wrap plans-teaser">
<h2>Piani per ogni esigenza</h2>
<p class="plans-teaser-lead">Inizia gratis. Passa a Premium quando vuoi più partite in contemporanea, archivio più lungo e diretta anche su YouTube.</p>
<div class="plans-teaser-visual">
<%= image_tag "/home-piani-ecosistema.png?v=1", alt: "Ecosistema Match Live TV: smartphone in diretta, laptop con live e statistiche, tablet con archivio partite e cloud", class: "plans-teaser-img", loading: "lazy" %>
</div>
<%= link_to "Confronta i piani", public_prezzi_path, class: "btn btn-primary" %>
</section>

View File

@@ -0,0 +1,72 @@
<% content_for :title, "Prezzi — Match Live TV" %>
<div class="wrap" style="padding-top:24px">
<h1>Piani per la tua squadra</h1>
<p style="color:#aaa">Abbonamento annuale per squadra: lo staff gestisce trasmissione e regia con credenziali dedicate. Pagamento sicuro su Stripe.</p>
<div class="plans">
<div class="plan-card">
<h3>Free</h3>
<div class="plan-price">€0</div>
<ul>
<li><strong>1</strong> persona staff / anno per la <strong>trasmissione</strong></li>
<li><strong>1</strong> persona staff / anno per la <strong>regia</strong></li>
<li><strong>1</strong> live alla volta su matchlivetv.eminux.it</li>
<li>Punteggio live e regia da app</li>
<li style="color:#888">No salvataggio / replay</li>
<li style="color:#888">No YouTube</li>
</ul>
<%= link_to "Inizia gratis", public_signup_path, class: "btn btn-secondary" %>
</div>
<div class="plan-card featured">
<h3>Premium Light</h3>
<div class="plan-price" style="color:#e53935">€40<span style="font-size:1rem;font-weight:400">/anno</span></div>
<p style="color:#aaa;font-size:0.9rem;margin:-8px 0 12px">oppure <strong>€5</strong>/mese</p>
<ul>
<li><strong>5</strong> persone staff / anno per la <strong>trasmissione</strong></li>
<li><strong>5</strong> persone staff / anno per la <strong>regia</strong></li>
<li>Fino a <strong>3</strong> partite in contemporanea</li>
<li>Live su matchlivetv.eminux.it e sul <strong>canale YouTube Match TV Light</strong></li>
<li>Archivio server <strong>30 giorni</strong> e download su telefono</li>
</ul>
<%= link_to "Registrati", public_signup_path, class: "btn btn-primary" %>
</div>
<div class="plan-card">
<h3>Premium Full</h3>
<div class="plan-price">€200<span style="font-size:1rem;font-weight:400">/anno</span></div>
<p style="color:#aaa;font-size:0.9rem;margin:-8px 0 12px">oppure <strong>€20</strong>/mese</p>
<ul>
<li>Staff <strong>illimitato</strong> / anno (trasmissione e regia)</li>
<li>Fino a <strong>10</strong> partite in contemporanea</li>
<li>Collegamento con il <strong>canale YouTube della società</strong></li>
<li>Archivio server <strong>90 giorni</strong> e download su telefono</li>
<li>Sito società (in arrivo)</li>
</ul>
<%= link_to "Registrati", public_signup_path, class: "btn btn-primary" %>
</div>
</div>
<table class="compare-table">
<thead>
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr>
</thead>
<tbody>
<tr><td>Staff trasmissione / anno</td><td>1</td><td>5</td><td>Illimitato</td></tr>
<tr><td>Staff regia / anno</td><td>1</td><td>5</td><td>Illimitato</td></tr>
<tr><td>Partite in contemporanea</td><td>1</td><td>3</td><td>10</td></tr>
<tr><td>Live su Match Live TV</td><td>Sì</td><td>Sì</td><td>Sì</td></tr>
<tr><td>YouTube</td><td>No</td><td>Match TV Light</td><td>Canale società</td></tr>
<tr><td>Replay server</td><td>No</td><td>30 gg</td><td>90 gg</td></tr>
<tr><td>Download telefono</td><td>No</td><td>Sì</td><td>Sì</td></tr>
<tr><td>Prezzo</td><td>€0</td><td>€40/anno o €5/mese</td><td>€200/anno o €20/mese</td></tr>
</tbody>
</table>
<div class="card" style="margin-top:32px;text-align:center">
<h3 style="margin-top:0">Esigenze diverse?</h3>
<p style="color:#aaa;margin-bottom:16px">Per società con più squadre, tornei o integrazioni custom, contattaci: troviamo insieme l'offerta migliore.</p>
<%= mail_to "info@matchlivetv.eminux.it", "Contattaci", class: "btn btn-primary" %>
</div>
</div>

View File

@@ -0,0 +1,9 @@
<% content_for :title, "Privacy — Match Live TV" %>
<div class="wrap" style="padding-top:24px">
<h1>Informativa privacy</h1>
<div class="card">
<p>Match Live TV tratta i dati necessari per account, gestione staff e streaming (email, nome, dati di utilizzo delle sessioni).</p>
<p>Per richieste o cancellazione account contattare il gestore del servizio all'indirizzo indicato nella dashboard della squadra.</p>
<p style="color:#888;font-size:0.9rem">Documento provvisorio — da completare con i dati del titolare del trattamento.</p>
</div>
</div>

View File

@@ -0,0 +1,9 @@
<% content_for :title, "Termini di servizio — Match Live TV" %>
<div class="wrap" style="padding-top:24px">
<h1>Termini di servizio</h1>
<div class="card">
<p>L'uso di Match Live TV implica l'accettazione dei limiti del piano sottoscritto (staff, dirette concorrenti, archivio).</p>
<p>Le dirette sono responsabilità della squadra titolare dell'account. È vietato l'uso per contenuti non conformi alle norme sulla protezione dei minori.</p>
<p style="color:#888;font-size:0.9rem">Documento provvisorio — da rivedere con consulenza legale.</p>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More