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