Replay fuori da Puma, fix ops cron e firma release Android.
Dopo auth Rails redirect a URL presigned su /media/ (edge → Garage); aumenta RAILS_MAX_THREADS, corregge ingest log ops via stdin e keystore Play Console. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -52,6 +52,15 @@ module Public
|
|||||||
private
|
private
|
||||||
|
|
||||||
def serve_video(disposition:)
|
def serve_video(disposition:)
|
||||||
|
expires_in = disposition == "attachment" ? MatchLiveTv.replay_download_url_ttl_seconds : MatchLiveTv.replay_presigned_url_ttl_seconds
|
||||||
|
redirect_url = Recordings::PublicMediaUrl.new(
|
||||||
|
key: @recording.storage_key,
|
||||||
|
expires_in: expires_in,
|
||||||
|
disposition: disposition,
|
||||||
|
filename: disposition == "attachment" ? download_filename : nil
|
||||||
|
).call
|
||||||
|
return redirect_to redirect_url, allow_other_host: true if redirect_url.present?
|
||||||
|
|
||||||
Recordings::ServeFromStorage.new(
|
Recordings::ServeFromStorage.new(
|
||||||
key: @recording.storage_key,
|
key: @recording.storage_key,
|
||||||
content_type: "video/mp4",
|
content_type: "video/mp4",
|
||||||
@@ -66,6 +75,9 @@ module Public
|
|||||||
key = @recording.thumbnail_storage_key
|
key = @recording.thumbnail_storage_key
|
||||||
return head :not_found if key.blank?
|
return head :not_found if key.blank?
|
||||||
|
|
||||||
|
redirect_url = Recordings::PublicMediaUrl.new(key: key, expires_in: MatchLiveTv.replay_presigned_url_ttl_seconds).call
|
||||||
|
return redirect_to redirect_url, allow_other_host: true if redirect_url.present?
|
||||||
|
|
||||||
Recordings::ServeFromStorage.new(
|
Recordings::ServeFromStorage.new(
|
||||||
key: key,
|
key: key,
|
||||||
content_type: "image/jpeg",
|
content_type: "image/jpeg",
|
||||||
|
|||||||
44
backend/app/services/recordings/public_media_url.rb
Normal file
44
backend/app/services/recordings/public_media_url.rb
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Recordings
|
||||||
|
# URL presigned S3 riscritto sul path pubblico /media/ (edge → Garage).
|
||||||
|
# Rails fa solo auth; il browser scarica/streama senza occupare Puma.
|
||||||
|
class PublicMediaUrl
|
||||||
|
def initialize(key:, expires_in: MatchLiveTv.replay_presigned_url_ttl_seconds, disposition: "inline", filename: nil)
|
||||||
|
@key = key
|
||||||
|
@expires_in = expires_in
|
||||||
|
@disposition = disposition
|
||||||
|
@filename = filename
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return nil if @key.blank?
|
||||||
|
return nil unless MatchLiveTv.replay_media_redirect_enabled?
|
||||||
|
|
||||||
|
raw = Recordings::Storage.new.presigned_get_url(
|
||||||
|
key: @key,
|
||||||
|
expires_in: @expires_in,
|
||||||
|
response_content_disposition: content_disposition_param
|
||||||
|
)
|
||||||
|
return nil if raw.blank?
|
||||||
|
|
||||||
|
rewrite_to_public(raw)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def rewrite_to_public(raw)
|
||||||
|
uri = URI.parse(raw)
|
||||||
|
base = MatchLiveTv.replay_media_public_base_url.chomp("/")
|
||||||
|
query = uri.query.presence
|
||||||
|
path = uri.path.start_with?("/") ? uri.path : "/#{uri.path}"
|
||||||
|
query ? "#{base}#{path}?#{query}" : "#{base}#{path}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def content_disposition_param
|
||||||
|
return nil unless @disposition == "attachment" && @filename.present?
|
||||||
|
|
||||||
|
%(attachment; filename="#{@filename.gsub('"', '\\"')}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -31,8 +31,12 @@ module Recordings
|
|||||||
dest
|
dest
|
||||||
end
|
end
|
||||||
|
|
||||||
def presigned_get_url(key:, expires_in: MatchLiveTv.replay_presigned_url_ttl_seconds)
|
def presigned_get_url(key:, expires_in: MatchLiveTv.replay_presigned_url_ttl_seconds, response_content_disposition: nil)
|
||||||
@backend.presigned_get_url(key: key, expires_in: expires_in)
|
@backend.presigned_get_url(
|
||||||
|
key: key,
|
||||||
|
expires_in: expires_in,
|
||||||
|
response_content_disposition: response_content_disposition
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def local_path_for_key(key)
|
def local_path_for_key(key)
|
||||||
@@ -61,7 +65,7 @@ module Recordings
|
|||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
def presigned_get_url(key:, expires_in:)
|
def presigned_get_url(key:, expires_in:, response_content_disposition: nil)
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -92,14 +96,15 @@ module Recordings
|
|||||||
raise Error, e.message
|
raise Error, e.message
|
||||||
end
|
end
|
||||||
|
|
||||||
def presigned_get_url(key:, expires_in:)
|
def presigned_get_url(key:, expires_in:, response_content_disposition: nil)
|
||||||
signer = Aws::S3::Presigner.new(client: client)
|
signer = Aws::S3::Presigner.new(client: client)
|
||||||
signer.presigned_url(
|
params = {
|
||||||
:get_object,
|
|
||||||
bucket: MatchLiveTv.replay_storage_bucket,
|
bucket: MatchLiveTv.replay_storage_bucket,
|
||||||
key: key,
|
key: key,
|
||||||
expires_in: expires_in
|
expires_in: expires_in
|
||||||
)
|
}
|
||||||
|
params[:response_content_disposition] = response_content_disposition if response_content_disposition.present?
|
||||||
|
signer.presigned_url(:get_object, **params)
|
||||||
end
|
end
|
||||||
|
|
||||||
def local_path_for_key(_key)
|
def local_path_for_key(_key)
|
||||||
|
|||||||
@@ -141,6 +141,16 @@ module MatchLiveTv
|
|||||||
ENV.fetch("REPLAY_DOWNLOAD_URL_TTL_SECONDS", "900").to_i
|
ENV.fetch("REPLAY_DOWNLOAD_URL_TTL_SECONDS", "900").to_i
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def replay_media_public_base_url
|
||||||
|
ENV.fetch("REPLAY_MEDIA_PUBLIC_BASE_URL") { "#{app_public_url.chomp('/')}/media" }
|
||||||
|
end
|
||||||
|
|
||||||
|
def replay_media_redirect_enabled?
|
||||||
|
return false if replay_storage_local?
|
||||||
|
|
||||||
|
ENV.fetch("REPLAY_MEDIA_REDIRECT", "true") == "true"
|
||||||
|
end
|
||||||
|
|
||||||
def google_analytics_measurement_id
|
def google_analytics_measurement_id
|
||||||
ENV["GOOGLE_ANALYTICS_MEASUREMENT_ID"].presence
|
ENV["GOOGLE_ANALYTICS_MEASUREMENT_ID"].presence
|
||||||
end
|
end
|
||||||
|
|||||||
51
backend/spec/services/recordings/public_media_url_spec.rb
Normal file
51
backend/spec/services/recordings/public_media_url_spec.rb
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Recordings::PublicMediaUrl do
|
||||||
|
before do
|
||||||
|
allow(MatchLiveTv).to receive(:replay_storage_local?).and_return(false)
|
||||||
|
allow(MatchLiveTv).to receive(:replay_media_redirect_enabled?).and_return(true)
|
||||||
|
allow(MatchLiveTv).to receive(:replay_media_public_base_url).and_return("https://www.matchlivetv.it/media")
|
||||||
|
allow(MatchLiveTv).to receive(:replay_presigned_url_ttl_seconds).and_return(7200)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "riscrive l'URL presigned Garage sul path pubblico /media/" do
|
||||||
|
storage = instance_double(Recordings::Storage)
|
||||||
|
allow(Recordings::Storage).to receive(:new).and_return(storage)
|
||||||
|
allow(storage).to receive(:presigned_get_url).and_return(
|
||||||
|
"http://garage:3900/matchlivetv-replays/teams/abc/video.mp4?X-Amz-Signature=abc"
|
||||||
|
)
|
||||||
|
|
||||||
|
url = described_class.new(key: "teams/abc/video.mp4").call
|
||||||
|
|
||||||
|
expect(url).to eq(
|
||||||
|
"https://www.matchlivetv.it/media/matchlivetv-replays/teams/abc/video.mp4?X-Amz-Signature=abc"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "restituisce nil se il redirect è disabilitato" do
|
||||||
|
allow(MatchLiveTv).to receive(:replay_media_redirect_enabled?).and_return(false)
|
||||||
|
|
||||||
|
expect(described_class.new(key: "teams/abc/video.mp4").call).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "passa content-disposition per i download" do
|
||||||
|
storage = instance_double(Recordings::Storage)
|
||||||
|
allow(Recordings::Storage).to receive(:new).and_return(storage)
|
||||||
|
allow(storage).to receive(:presigned_get_url).and_return("http://garage:3900/bucket/key.mp4?sig=1")
|
||||||
|
|
||||||
|
described_class.new(
|
||||||
|
key: "key.mp4",
|
||||||
|
disposition: "attachment",
|
||||||
|
filename: "partita.mp4"
|
||||||
|
).call
|
||||||
|
|
||||||
|
expect(storage).to have_received(:presigned_get_url).with(
|
||||||
|
hash_including(
|
||||||
|
key: "key.mp4",
|
||||||
|
response_content_disposition: %(attachment; filename="partita.mp4")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -66,6 +66,12 @@ REPLAY_STORAGE_REGION=garage
|
|||||||
REPLAY_STORAGE_ACCESS_KEY_ID=
|
REPLAY_STORAGE_ACCESS_KEY_ID=
|
||||||
REPLAY_STORAGE_SECRET_ACCESS_KEY=
|
REPLAY_STORAGE_SECRET_ACCESS_KEY=
|
||||||
REPLAY_STORAGE_FORCE_PATH_STYLE=true
|
REPLAY_STORAGE_FORCE_PATH_STYLE=true
|
||||||
|
# Streaming replay: Rails fa auth, edge serve /media/ → Garage (non passa da Puma)
|
||||||
|
REPLAY_MEDIA_PUBLIC_BASE_URL=https://www.matchlivetv.it/media
|
||||||
|
REPLAY_MEDIA_REDIRECT=true
|
||||||
|
|
||||||
|
# Puma: 5 thread (no secondo worker senza più RAM)
|
||||||
|
RAILS_MAX_THREADS=5
|
||||||
|
|
||||||
# Ops monitoring — notifiche push (ntfy self-hosted) e health check
|
# Ops monitoring — notifiche push (ntfy self-hosted) e health check
|
||||||
# 1) Avvia servizio: docker compose up -d ntfy
|
# 1) Avvia servizio: docker compose up -d ntfy
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ services:
|
|||||||
command: bundle exec rails server -b 0.0.0.0 -p 3000 -e production
|
command: bundle exec rails server -b 0.0.0.0 -p 3000 -e production
|
||||||
environment:
|
environment:
|
||||||
RAILS_ENV: production
|
RAILS_ENV: production
|
||||||
|
RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5}
|
||||||
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/matchlivetv
|
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/matchlivetv
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
MEDIAMTX_API_URL: http://mediamtx:9997
|
MEDIAMTX_API_URL: http://mediamtx:9997
|
||||||
@@ -157,6 +158,7 @@ services:
|
|||||||
command: bundle exec sidekiq -C config/sidekiq.yml -e production
|
command: bundle exec sidekiq -C config/sidekiq.yml -e production
|
||||||
environment:
|
environment:
|
||||||
RAILS_ENV: production
|
RAILS_ENV: production
|
||||||
|
RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5}
|
||||||
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/matchlivetv
|
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/matchlivetv
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
MEDIAMTX_API_URL: http://mediamtx:9997
|
MEDIAMTX_API_URL: http://mediamtx:9997
|
||||||
|
|||||||
@@ -22,6 +22,28 @@ http {
|
|||||||
|
|
||||||
set $rails_backend rails:3000;
|
set $rails_backend rails:3000;
|
||||||
|
|
||||||
|
# Replay MP4/thumbnail: solo lettura da Garage (URL presigned da Rails).
|
||||||
|
location /media/ {
|
||||||
|
limit_except GET HEAD {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
rewrite ^/media/(.*)$ /$1 break;
|
||||||
|
proxy_pass http://garage:3900;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
# Presigned S3 firmati sull'endpoint interno garage:3900
|
||||||
|
proxy_set_header Host garage:3900;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Range $http_range;
|
||||||
|
proxy_set_header If-Range $http_if_range;
|
||||||
|
}
|
||||||
|
|
||||||
location = /edge-health {
|
location = /edge-health {
|
||||||
access_log off;
|
access_log off;
|
||||||
add_header Content-Type text/plain;
|
add_header Content-Type text/plain;
|
||||||
|
|||||||
@@ -32,4 +32,5 @@ if [[ ! -s "$TMP" ]]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
"${ROOT}/scripts/cron/run_rails_task.sh" ops:ingest_logs["$TMP"]
|
# Il file temp è sull'host: passa il contenuto via stdin al container Rails.
|
||||||
|
"${ROOT}/scripts/cron/run_rails_task.sh" ops:ingest_logs < "$TMP"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ CRON_BLOCK="${MARKER}
|
|||||||
30 * * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:cleanup_local >> ${LOG_FILE} 2>&1
|
30 * * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:cleanup_local >> ${LOG_FILE} 2>&1
|
||||||
0 3 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:purge_expired >> ${LOG_FILE} 2>&1
|
0 3 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:purge_expired >> ${LOG_FILE} 2>&1
|
||||||
0 8 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:expiry_warnings >> ${LOG_FILE} 2>&1
|
0 8 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:expiry_warnings >> ${LOG_FILE} 2>&1
|
||||||
*/10 * * * * mkdir -p ${LOG_DIR} && ${SCAN_LOGS} >> ${OPS_LOG_FILE} 2>&1
|
*/10 * * * * mkdir -p ${LOG_DIR} && /bin/bash ${SCAN_LOGS} >> ${OPS_LOG_FILE} 2>&1
|
||||||
"
|
"
|
||||||
|
|
||||||
existing="$(crontab -l 2>/dev/null || true)"
|
existing="$(crontab -l 2>/dev/null || true)"
|
||||||
|
|||||||
2
native/android/.gitignore
vendored
2
native/android/.gitignore
vendored
@@ -6,3 +6,5 @@
|
|||||||
/captures/
|
/captures/
|
||||||
/app/build/
|
/app/build/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
keystore.properties
|
||||||
|
keystore/
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
id("org.jetbrains.kotlin.plugin.compose")
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.matchlivetv.match_live_tv"
|
namespace = "com.matchlivetv.match_live_tv"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
@@ -21,6 +29,17 @@ android {
|
|||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
create("release") {
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String
|
||||||
|
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
@@ -28,7 +47,11 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
)
|
)
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||||
|
signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
scripts/backup_android_keystore.sh
Executable file
24
scripts/backup_android_keystore.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Backup locale del keystore Android (Play Console upload key).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
ANDROID="${ROOT}/native/android"
|
||||||
|
KEYSTORE_DIR="${ANDROID}/keystore"
|
||||||
|
PROPS="${ANDROID}/keystore.properties"
|
||||||
|
DEST="${ANDROID_KEYSTORE_BACKUP_DIR:-${HOME}/.matchlivetv/android-keystore-backup}"
|
||||||
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
|
||||||
|
if [ ! -f "${PROPS}" ] || [ ! -d "${KEYSTORE_DIR}" ]; then
|
||||||
|
echo "ERRORE: keystore non trovato in ${ANDROID}"
|
||||||
|
echo "Genera prima il keystore di release (vedi native/android/)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${DEST}/${STAMP}"
|
||||||
|
cp -a "${PROPS}" "${DEST}/${STAMP}/"
|
||||||
|
cp -a "${KEYSTORE_DIR}/." "${DEST}/${STAMP}/keystore/"
|
||||||
|
chmod -R u=rwX,go= "${DEST}/${STAMP}"
|
||||||
|
|
||||||
|
echo "Backup keystore Android: ${DEST}/${STAMP}"
|
||||||
|
echo "Conserva anche una copia off-site (password manager / cloud cifrato)."
|
||||||
@@ -70,7 +70,8 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Proxy edge + deploy Rails (sposta :3000 su edge)"
|
echo "==> Proxy edge + deploy Rails (sposta :3000 su edge)"
|
||||||
compose up -d --no-deps --build edge rails
|
compose up -d --no-deps --force-recreate edge
|
||||||
|
compose up -d --no-deps --build rails
|
||||||
wait_for_rails
|
wait_for_rails
|
||||||
|
|
||||||
echo "==> Deploy Sidekiq"
|
echo "==> Deploy Sidekiq"
|
||||||
|
|||||||
Reference in New Issue
Block a user