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:
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal 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
70
README.md
Normal 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
42
backend/.dockerignore
Normal 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
9
backend/.gitattributes
vendored
Normal 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
12
backend/.github/dependabot.yml
vendored
Normal 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
39
backend/.github/workflows/ci.yml
vendored
Normal 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
33
backend/.gitignore
vendored
Normal 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
8
backend/.rubocop.yml
Normal 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
1
backend/.ruby-version
Normal file
@@ -0,0 +1 @@
|
||||
ruby-3.3.11
|
||||
19
backend/Dockerfile
Normal file
19
backend/Dockerfile
Normal 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
33
backend/Gemfile
Normal 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
443
backend/Gemfile.lock
Normal 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
24
backend/README.md
Normal 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
6
backend/Rakefile
Normal 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
|
||||
4
backend/app/channels/application_cable/channel.rb
Normal file
4
backend/app/channels/application_cable/channel.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
module ApplicationCable
|
||||
class Channel < ActionCable::Channel::Base
|
||||
end
|
||||
end
|
||||
17
backend/app/channels/application_cable/connection.rb
Normal file
17
backend/app/channels/application_cable/connection.rb
Normal 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
|
||||
9
backend/app/channels/concerns/cable_broadcastable.rb
Normal file
9
backend/app/channels/concerns/cable_broadcastable.rb
Normal 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
|
||||
83
backend/app/channels/session_channel.rb
Normal file
83
backend/app/channels/session_channel.rb
Normal 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
|
||||
6
backend/app/controllers/admin/base_controller.rb
Normal file
6
backend/app/controllers/admin/base_controller.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
module Admin
|
||||
class BaseController < ActionController::Base
|
||||
layout "admin"
|
||||
protect_from_forgery with: :null_session
|
||||
end
|
||||
end
|
||||
8
backend/app/controllers/admin/dashboard_controller.rb
Normal file
8
backend/app/controllers/admin/dashboard_controller.rb
Normal 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
|
||||
12
backend/app/controllers/admin/sessions_controller.rb
Normal file
12
backend/app/controllers/admin/sessions_controller.rb
Normal 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
|
||||
12
backend/app/controllers/admin/teams_controller.rb
Normal file
12
backend/app/controllers/admin/teams_controller.rb
Normal 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
|
||||
65
backend/app/controllers/api/v1/auth_controller.rb
Normal file
65
backend/app/controllers/api/v1/auth_controller.rb
Normal 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
|
||||
17
backend/app/controllers/api/v1/base_controller.rb
Normal file
17
backend/app/controllers/api/v1/base_controller.rb
Normal 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
|
||||
85
backend/app/controllers/api/v1/matches_controller.rb
Normal file
85
backend/app/controllers/api/v1/matches_controller.rb
Normal 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
|
||||
146
backend/app/controllers/api/v1/stream_sessions_controller.rb
Normal file
146
backend/app/controllers/api/v1/stream_sessions_controller.rb
Normal 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
|
||||
122
backend/app/controllers/api/v1/teams_controller.rb
Normal file
122
backend/app/controllers/api/v1/teams_controller.rb
Normal 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
|
||||
26
backend/app/controllers/api/v1/youtube_controller.rb
Normal file
26
backend/app/controllers/api/v1/youtube_controller.rb
Normal 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
|
||||
25
backend/app/controllers/application_controller.rb
Normal file
25
backend/app/controllers/application_controller.rb
Normal 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
|
||||
0
backend/app/controllers/concerns/.keep
Normal file
0
backend/app/controllers/concerns/.keep
Normal file
44
backend/app/controllers/hls_proxy_controller.rb
Normal file
44
backend/app/controllers/hls_proxy_controller.rb
Normal 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
|
||||
28
backend/app/controllers/public/invitations_controller.rb
Normal file
28
backend/app/controllers/public/invitations_controller.rb
Normal 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
|
||||
47
backend/app/controllers/public/live_controller.rb
Normal file
47
backend/app/controllers/public/live_controller.rb
Normal 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
|
||||
19
backend/app/controllers/public/pages_controller.rb
Normal file
19
backend/app/controllers/public/pages_controller.rb
Normal 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
|
||||
33
backend/app/controllers/public/registrations_controller.rb
Normal file
33
backend/app/controllers/public/registrations_controller.rb
Normal 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
|
||||
15
backend/app/controllers/public/replay_controller.rb
Normal file
15
backend/app/controllers/public/replay_controller.rb
Normal 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
|
||||
24
backend/app/controllers/public/sessions_controller.rb
Normal file
24
backend/app/controllers/public/sessions_controller.rb
Normal 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
|
||||
19
backend/app/controllers/public/site_base_controller.rb
Normal file
19
backend/app/controllers/public/site_base_controller.rb
Normal 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
|
||||
119
backend/app/controllers/public/teams_controller.rb
Normal file
119
backend/app/controllers/public/teams_controller.rb
Normal 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
|
||||
18
backend/app/controllers/public/web_base_controller.rb
Normal file
18
backend/app/controllers/public/web_base_controller.rb
Normal 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
|
||||
53
backend/app/controllers/webhooks/mediamtx_controller.rb
Normal file
53
backend/app/controllers/webhooks/mediamtx_controller.rb
Normal 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
|
||||
32
backend/app/controllers/webhooks/stripe_controller.rb
Normal file
32
backend/app/controllers/webhooks/stripe_controller.rb
Normal 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
|
||||
35
backend/app/helpers/public/live_helper.rb
Normal file
35
backend/app/helpers/public/live_helper.rb
Normal 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
|
||||
7
backend/app/jobs/application_job.rb
Normal file
7
backend/app/jobs/application_job.rb
Normal 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
|
||||
12
backend/app/jobs/cleanup_expired_sessions_job.rb
Normal file
12
backend/app/jobs/cleanup_expired_sessions_job.rb
Normal 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
|
||||
22
backend/app/jobs/disconnection_timeout_job.rb
Normal file
22
backend/app/jobs/disconnection_timeout_job.rb
Normal 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
|
||||
17
backend/app/jobs/notify_stream_health_job.rb
Normal file
17
backend/app/jobs/notify_stream_health_job.rb
Normal 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
|
||||
16
backend/app/jobs/stream_analytics_job.rb
Normal file
16
backend/app/jobs/stream_analytics_job.rb
Normal 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
|
||||
4
backend/app/mailers/application_mailer.rb
Normal file
4
backend/app/mailers/application_mailer.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: "from@example.com"
|
||||
layout "mailer"
|
||||
end
|
||||
3
backend/app/models/application_record.rb
Normal file
3
backend/app/models/application_record.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
primary_abstract_class
|
||||
end
|
||||
0
backend/app/models/concerns/.keep
Normal file
0
backend/app/models/concerns/.keep
Normal file
19
backend/app/models/device_state.rb
Normal file
19
backend/app/models/device_state.rb
Normal 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
|
||||
7
backend/app/models/match.rb
Normal file
7
backend/app/models/match.rb
Normal 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
|
||||
86
backend/app/models/plan.rb
Normal file
86
backend/app/models/plan.rb
Normal 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
|
||||
21
backend/app/models/recording.rb
Normal file
21
backend/app/models/recording.rb
Normal 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
|
||||
17
backend/app/models/score_state.rb
Normal file
17
backend/app/models/score_state.rb
Normal 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
|
||||
13
backend/app/models/stream_event.rb
Normal file
13
backend/app/models/stream_event.rb
Normal 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
|
||||
150
backend/app/models/stream_session.rb
Normal file
150
backend/app/models/stream_session.rb
Normal 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
|
||||
28
backend/app/models/subscription.rb
Normal file
28
backend/app/models/subscription.rb
Normal 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
|
||||
19
backend/app/models/team.rb
Normal file
19
backend/app/models/team.rb
Normal 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
|
||||
28
backend/app/models/team_invitation.rb
Normal file
28
backend/app/models/team_invitation.rb
Normal 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
|
||||
17
backend/app/models/user.rb
Normal file
17
backend/app/models/user.rb
Normal 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
|
||||
10
backend/app/models/user_team.rb
Normal file
10
backend/app/models/user_team.rb
Normal 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
|
||||
22
backend/app/models/youtube_credential.rb
Normal file
22
backend/app/models/youtube_credential.rb
Normal 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
|
||||
12
backend/app/services/billing/assign_plan.rb
Normal file
12
backend/app/services/billing/assign_plan.rb
Normal 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
|
||||
79
backend/app/services/billing/stripe/checkout_session.rb
Normal file
79
backend/app/services/billing/stripe/checkout_session.rb
Normal 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
|
||||
131
backend/app/services/billing/stripe/webhook_handler.rb
Normal file
131
backend/app/services/billing/stripe/webhook_handler.rb
Normal 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
|
||||
101
backend/app/services/mediamtx/client.rb
Normal file
101
backend/app/services/mediamtx/client.rb
Normal 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
|
||||
25
backend/app/services/recordings/index_session.rb
Normal file
25
backend/app/services/recordings/index_session.rb
Normal 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
|
||||
61
backend/app/services/sessions/create.rb
Normal file
61
backend/app/services/sessions/create.rb
Normal 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
|
||||
20
backend/app/services/sessions/pause.rb
Normal file
20
backend/app/services/sessions/pause.rb
Normal 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
|
||||
21
backend/app/services/sessions/start.rb
Normal file
21
backend/app/services/sessions/start.rb
Normal 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
|
||||
30
backend/app/services/sessions/stop.rb
Normal file
30
backend/app/services/sessions/stop.rb
Normal 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
|
||||
11
backend/app/services/teams/entitlement_error.rb
Normal file
11
backend/app/services/teams/entitlement_error.rb
Normal 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
|
||||
239
backend/app/services/teams/entitlements.rb
Normal file
239
backend/app/services/teams/entitlements.rb
Normal 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
|
||||
94
backend/app/services/webhooks/mediamtx_handler.rb
Normal file
94
backend/app/services/webhooks/mediamtx_handler.rb
Normal 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
|
||||
14
backend/app/services/webhooks/signature.rb
Normal file
14
backend/app/services/webhooks/signature.rb
Normal 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
|
||||
89
backend/app/services/youtube/broadcast_service.rb
Normal file
89
backend/app/services/youtube/broadcast_service.rb
Normal 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
|
||||
25
backend/app/services/youtube/oauth_exchange.rb
Normal file
25
backend/app/services/youtube/oauth_exchange.rb
Normal 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
|
||||
31
backend/app/services/youtube/oauth_refresh.rb
Normal file
31
backend/app/services/youtube/oauth_refresh.rb
Normal 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
|
||||
22
backend/app/services/youtube/oauth_url.rb
Normal file
22
backend/app/services/youtube/oauth_url.rb
Normal 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
|
||||
25
backend/app/views/admin/dashboard/index.html.erb
Normal file
25
backend/app/views/admin/dashboard/index.html.erb
Normal 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>
|
||||
14
backend/app/views/admin/sessions/index.html.erb
Normal file
14
backend/app/views/admin/sessions/index.html.erb
Normal 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>
|
||||
21
backend/app/views/admin/sessions/show.html.erb
Normal file
21
backend/app/views/admin/sessions/show.html.erb
Normal 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>
|
||||
13
backend/app/views/admin/teams/index.html.erb
Normal file
13
backend/app/views/admin/teams/index.html.erb
Normal 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>
|
||||
10
backend/app/views/admin/teams/show.html.erb
Normal file
10
backend/app/views/admin/teams/show.html.erb
Normal 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>
|
||||
32
backend/app/views/layouts/admin.html.erb
Normal file
32
backend/app/views/layouts/admin.html.erb
Normal 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>
|
||||
13
backend/app/views/layouts/mailer.html.erb
Normal file
13
backend/app/views/layouts/mailer.html.erb
Normal 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>
|
||||
1
backend/app/views/layouts/mailer.text.erb
Normal file
1
backend/app/views/layouts/mailer.text.erb
Normal file
@@ -0,0 +1 @@
|
||||
<%= yield %>
|
||||
20
backend/app/views/layouts/marketing.html.erb
Normal file
20
backend/app/views/layouts/marketing.html.erb
Normal 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>
|
||||
20
backend/app/views/layouts/marketing_live.html.erb
Normal file
20
backend/app/views/layouts/marketing_live.html.erb
Normal 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>
|
||||
56
backend/app/views/layouts/public.html.erb
Normal file
56
backend/app/views/layouts/public.html.erb
Normal 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>
|
||||
12
backend/app/views/public/invitations/show.html.erb
Normal file
12
backend/app/views/public/invitations/show.html.erb
Normal 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>
|
||||
76
backend/app/views/public/live/index.html.erb
Normal file
76
backend/app/views/public/live/index.html.erb
Normal 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 dall’app, la partita comparirà qui.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
227
backend/app/views/public/live/show.html.erb
Normal file
227
backend/app/views/public/live/show.html.erb
Normal 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>
|
||||
63
backend/app/views/public/pages/features.html.erb
Normal file
63
backend/app/views/public/pages/features.html.erb
Normal 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>
|
||||
88
backend/app/views/public/pages/home.html.erb
Normal file
88
backend/app/views/public/pages/home.html.erb
Normal 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>
|
||||
72
backend/app/views/public/pages/pricing.html.erb
Normal file
72
backend/app/views/public/pages/pricing.html.erb
Normal 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>
|
||||
9
backend/app/views/public/pages/privacy.html.erb
Normal file
9
backend/app/views/public/pages/privacy.html.erb
Normal 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>
|
||||
9
backend/app/views/public/pages/terms.html.erb
Normal file
9
backend/app/views/public/pages/terms.html.erb
Normal 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
Reference in New Issue
Block a user