Completa modulo Replay: S3, retention, sync YouTube e gestione società.

Streaming chunked da Garage, purge con delete YouTube, privacy sincronizzata,
archivio club migliorato, retention 30/90 separata da stato abbonamento.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 09:33:59 +02:00
parent ae36d17adb
commit 74eee24293
17 changed files with 385 additions and 34 deletions

View File

@@ -5,7 +5,10 @@ module Api
def update
authorize_manage!
@recording.update!(recording_update_attrs)
attrs = recording_update_attrs
privacy_changed = attrs.key?(:privacy_status) && attrs[:privacy_status] != @recording.privacy_status
@recording.update!(attrs)
Recordings::SyncYoutubePrivacyJob.perform_async(@recording.id) if privacy_changed && @recording.youtube_video_id.present?
render json: recording_json(@recording)
end
@@ -96,7 +99,10 @@ module Api
youtube_video_id: recording.youtube_video_id,
youtube_watch_url: recording.youtube_watch_url,
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?,
source_platform: recording.source_platform,
source_platform_label: recording.source_platform_label,
expires_at: recording.expires_at,
days_until_expiry: recording.days_until_expiry,
metadata: recording.metadata
}
end

View File

@@ -42,6 +42,7 @@ module Api
recs = team.recordings.not_deleted
.where(status: %w[ready processing])
.where("expires_at IS NULL OR expires_at > ?", Time.current)
.includes(stream_session: :match)
.order(recorded_at: :desc, created_at: :desc)
.limit(50)
@@ -167,7 +168,10 @@ module Api
youtube_video_id: recording.youtube_video_id,
youtube_watch_url: recording.youtube_watch_url,
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?,
source_platform: recording.source_platform,
source_platform_label: recording.source_platform_label,
expires_at: recording.expires_at,
days_until_expiry: recording.days_until_expiry,
metadata: recording.metadata
}
end

View File

@@ -2,7 +2,7 @@ module Public
class ClubRecordingsController < WebBaseController
before_action :require_login!
before_action :set_club
before_action -> { require_club_owner!(@club) }
before_action :require_club_recording_manager!
before_action :set_recording, only: %i[update destroy publish_youtube]
def index
@@ -11,17 +11,39 @@ module Public
.not_deleted
.includes(stream_session: { match: :team })
.order(recorded_at: :desc, created_at: :desc)
if params[:team_id].present?
@recordings = @recordings.where(team_id: params[:team_id])
end
if params[:status].present?
case params[:status]
when "ready"
@recordings = @recordings.ready
when "processing"
@recordings = @recordings.where(status: "processing")
when "expired"
@recordings = @recordings.where(
"recordings.status IN (?) OR (recordings.expires_at IS NOT NULL AND recordings.expires_at <= ?)",
%w[expired failed], Time.current
)
end
end
@teams = @club.teams.order(:name)
@filter_team_id = params[:team_id]
@filter_status = params[:status]
end
def update
privacy = params.require(:recording).permit(:privacy_status, :title)[:privacy_status]
permitted = params.require(:recording).permit(:privacy_status, :title)
attrs = {}
privacy = permitted[:privacy_status]
attrs[:privacy_status] = privacy if privacy.in?(Recording::PRIVACY_STATUSES)
title = params.dig(:recording, :title)
title = permitted[:title]
attrs[:title] = title if title.present?
privacy_changed = attrs.key?(:privacy_status) && attrs[:privacy_status] != @recording.privacy_status
@recording.update!(attrs)
redirect_to public_club_recordings_path(@club), notice: "Replay aggiornato"
Recordings::SyncYoutubePrivacyJob.perform_async(@recording.id) if privacy_changed && @recording.youtube_video_id.present?
redirect_to public_club_recordings_path(@club, team_id: params[:team_id], status: params[:status]),
notice: "Replay aggiornato"
rescue ActiveRecord::RecordInvalid => e
redirect_to public_club_recordings_path(@club), alert: e.record.errors.full_messages.join(", ")
end
@@ -45,11 +67,21 @@ module Public
private
def set_club
@club = current_user.owned_clubs.find(params[:club_id])
@club = Club.find(params[:club_id])
end
def require_club_recording_manager!
return if current_user.owned_clubs.exists?(id: @club.id)
return if current_user.manageable_teams.joins(:club).exists?(clubs: { id: @club.id })
redirect_to public_clubs_path, alert: "Non autorizzato"
end
def set_recording
@recording = Recording.for_club(@club).not_deleted.find(params[:id])
return if Recordings::AccessPolicy.new(@recording, viewer: current_user).manage_allowed?
raise ActiveRecord::RecordNotFound
end
end
end

View File

@@ -0,0 +1,17 @@
module Recordings
class SyncYoutubePrivacyJob
include Sidekiq::Job
sidekiq_options retry: 2, queue: "default"
def perform(recording_id)
recording = Recording.find_by(id: recording_id)
return unless recording&.youtube_video_id.present?
Youtube::VideoLifecycleService.new(recording).update_privacy!(
privacy_status: recording.privacy_status
)
rescue Youtube::VideoLifecycleService::Error => e
Rails.logger.warn("[SyncYoutubePrivacyJob] #{recording_id}: #{e.message}")
end
end
end

View File

@@ -141,6 +141,28 @@ class Recording < ApplicationRecord
count == 1 ? "1 visualizzazione" : "#{count} visualizzazioni"
end
def source_platform
metadata.is_a?(Hash) ? metadata["source_platform"].presence : nil
end
def source_platform_label
case source_platform
when "youtube" then "YouTube"
when "matchlivetv" then "Match Live TV"
else ""
end
end
def playable_on_site?
ready? && storage_key.present?
end
def days_until_expiry
return nil unless expires_at
((expires_at - Time.current) / 1.day).ceil
end
def status_label
return "Eliminato" if deleted?
return "Scaduto" if status == "expired"

View File

@@ -6,6 +6,7 @@ module Recordings
end
def call
delete_youtube_video
delete_storage_object if @recording.storage_key.present?
cleanup_local_artifacts
@@ -13,13 +14,23 @@ module Recordings
status: "expired",
deleted_at: Time.current,
storage_key: nil,
thumbnail_storage_key: nil
thumbnail_storage_key: nil,
youtube_video_id: nil,
youtube_published_at: nil
)
@recording
end
private
def delete_youtube_video
return if @recording.youtube_video_id.blank?
Youtube::VideoLifecycleService.new(@recording).delete!
rescue Youtube::VideoLifecycleService::Error => e
Rails.logger.warn("[Recordings::Delete] YouTube delete #{@recording.id}: #{e.message}")
end
def delete_storage_object
storage = Recordings::Storage.new
storage.delete(key: @recording.storage_key) if @recording.storage_key.present?

View File

@@ -6,7 +6,7 @@ module Recordings
def call
team = @session.match.team
return unless team.entitlements.can_access_recordings?
return unless team.entitlements.can_create_recordings?
retention_days = team.entitlements.recording_retention_days
expires_at = retention_days.positive? ? retention_days.days.from_now : nil

View File

@@ -5,6 +5,8 @@ require "aws-sdk-s3"
module Recordings
# Serve file replay/thumbnail al browser senza esporre URL S3 interni (es. http://garage:3900).
class ServeFromStorage
CHUNK_SIZE = 256 * 1024
def initialize(key:, content_type:, disposition: "inline", filename: nil)
@key = key
@content_type = content_type
@@ -14,7 +16,15 @@ module Recordings
def call(controller)
path = local_path
return controller.send_file(path, type: @content_type, disposition: @disposition, filename: @filename) if path
if path
return controller.send_file(
path,
type: @content_type,
disposition: @disposition,
filename: @filename,
stream: true
)
end
proxy_s3(controller)
end
@@ -41,22 +51,24 @@ module Recordings
controller.response.headers["Accept-Ranges"] = "bytes"
controller.response.headers["Content-Length"] = resp.content_length.to_s if resp.content_length
controller.response.headers["Content-Range"] = resp.content_range if resp.content_range.present?
controller.response.headers["Content-Disposition"] = content_disposition_header
data = resp.body.read
max = 1.gigabyte
raise ArgumentError, "File troppo grande per il proxy" if data.bytesize > max
if @disposition == "attachment" && @filename.present?
controller.send_data(
data,
type: @content_type,
disposition: "attachment",
filename: @filename
)
else
controller.send_data(data, type: @content_type, disposition: "inline")
end
controller.status = :partial_content if range.present? && resp.content_range.present?
body = resp.body
controller.response_body = Enumerator.new do |yielder|
while (chunk = body.read(CHUNK_SIZE))
yielder << chunk
end
end
end
def content_disposition_header
if @disposition == "attachment" && @filename.present?
%(attachment; filename="#{@filename.gsub('"', '\\"')}")
else
"inline"
end
end
def s3_client
@@ -66,7 +78,6 @@ module Recordings
endpoint: MatchLiveTv.replay_storage_endpoint,
region: MatchLiveTv.replay_storage_region,
force_path_style: MatchLiveTv.replay_storage_force_path_style?,
# Garage non sempre allinea i checksum AWS su Range/read parziali
response_checksum_validation: "WHEN_REQUIRED"
)
end

View File

@@ -178,12 +178,18 @@ module Teams
assert_can_stream_on!("youtube")
end
def can_access_recordings?
# Nuove registrazioni (MediaMTX + FinalizeSession): richiede abbonamento attivo.
def can_create_recordings?
active? && plan.recordings_enabled?
end
# Archivio esistente: visibile fino a expires_at anche se l'abbonamento è scaduto.
def can_access_recordings?
plan.recordings_enabled? || @team.recordings.not_deleted.exists?
end
def recording_enabled_for_mediamtx?
can_access_recordings?
can_create_recordings?
end
def recording_retention_days
@@ -215,6 +221,7 @@ module Teams
concurrent_streams_used: concurrent_streams_used,
concurrent_streams_limit: concurrent_streams_limit,
recordings_enabled: can_access_recordings?,
can_create_recordings: can_create_recordings?,
recording_retention_days: recording_retention_days,
phone_download_enabled: phone_download_enabled?,
youtube_enabled: youtube_enabled?,

View File

@@ -0,0 +1,66 @@
module Youtube
# Aggiorna privacy o elimina un VOD YouTube collegato a un replay.
class VideoLifecycleService
class Error < StandardError; end
def initialize(recording)
@recording = recording
@team = recording.team
@session = recording.stream_session
end
def update_privacy!(privacy_status:)
video_id = @recording.youtube_video_id
return if video_id.blank? || video_id.to_s.start_with?("mock_")
privacy = map_privacy(privacy_status)
client = authorized_client
video = Google::Apis::YoutubeV3::Video.new(
id: video_id,
status: Google::Apis::YoutubeV3::VideoStatus.new(
privacy_status: privacy,
self_declared_made_for_kids: false
)
)
client.update_video("status", video)
merge_youtube_meta!("privacy_status" => privacy)
true
rescue Google::Apis::Error => e
raise Error, e.message
end
def delete!
video_id = @recording.youtube_video_id
return if video_id.blank? || video_id.to_s.start_with?("mock_")
client = authorized_client
client.delete_video(video_id)
true
rescue Google::Apis::Error => e
raise Error, e.message
end
private
def map_privacy(status)
status.to_s == "public" ? "public" : "unlisted"
end
def merge_youtube_meta!(attrs)
meta = @recording.metadata.is_a?(Hash) ? @recording.metadata.dup : {}
publish = meta.fetch("youtube_publish", {}).merge(attrs)
@recording.update!(metadata: meta.merge("youtube_publish" => publish))
end
def youtube_channel
@session&.platform == "youtube" ? "team" : nil
end
def authorized_client
credential = CredentialResolver.new(@team, channel: youtube_channel).resolve
raise Error, "Credenziali YouTube non disponibili" if credential.blank?
OauthRefresh.new(credential).apply!(Google::Apis::YoutubeV3::YouTubeService.new)
end
end
end

View File

@@ -5,7 +5,11 @@
<%= link_to "← #{@club.name}", public_club_path(@club), class: "back-link" %>
<h1>Archivio Replay</h1>
<p class="muted">Gestisci registrazioni, visibilità, download e pubblicazione YouTube.</p>
<p class="muted">
Gestisci registrazioni, visibilità e download.
<strong>Privato</strong> = link non indicizzato, non compare in <code>/replay</code> pubblico.
Eliminando un replay vengono rimossi anche i file sul server e il video YouTube collegato.
</p>
<div class="kpi-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin:20px 0">
<div class="card" style="padding:16px">
@@ -26,6 +30,23 @@
</div>
</div>
<%= form_with url: public_club_recordings_path(@club), method: :get, local: true, class: "replay-filter-form" do %>
<label for="team_id" class="muted">Squadra</label>
<select name="team_id" id="team_id" class="input" onchange="this.form.submit()">
<option value="">Tutte</option>
<% @teams.each do |team| %>
<option value="<%= team.id %>"<%= " selected" if @filter_team_id.to_s == team.id.to_s %>><%= team.name %></option>
<% end %>
</select>
<label for="status" class="muted">Stato</label>
<select name="status" id="status" class="input" onchange="this.form.submit()">
<option value="">Tutti</option>
<option value="ready"<%= " selected" if @filter_status == "ready" %>>Disponibili</option>
<option value="processing"<%= " selected" if @filter_status == "processing" %>>In elaborazione</option>
<option value="expired"<%= " selected" if @filter_status == "expired" %>>Scaduti / errore</option>
</select>
<% end %>
<% if @recordings.any? %>
<div class="card">
<table class="data">
@@ -33,6 +54,7 @@
<tr>
<th></th>
<th>Titolo</th>
<th>Piattaforma</th>
<th>Data</th>
<th>Durata</th>
<th>Views</th>
@@ -50,22 +72,40 @@
<img src="<%= rec.thumbnail_url %>" alt="" width="64" height="36" style="object-fit:cover;border-radius:4px">
<% end %>
</td>
<td><%= rec.title_or_default %></td>
<td>
<%= form_with url: public_club_recording_path(@club, rec), method: :patch, local: true do %>
<%= hidden_field_tag :team_id, @filter_team_id if @filter_team_id.present? %>
<%= hidden_field_tag :status, @filter_status if @filter_status.present? %>
<%= text_field_tag "recording[title]", rec.title_or_default, class: "input", style: "min-width:12rem", onchange: "this.form.submit()" %>
<% end %>
</td>
<td><%= rec.source_platform_label %></td>
<td><%= l_local(rec.recorded_at_or_fallback) %></td>
<td><%= rec.duration_label %></td>
<td><%= rec.view_count %></td>
<td><%= rec.expires_at ? l_local(rec.expires_at) : "—" %></td>
<td>
<% if rec.expires_at %>
<%= l_local(rec.expires_at) %>
<% if rec.days_until_expiry && rec.days_until_expiry.positive? %>
<br><span class="muted" style="font-size:0.75rem">(<%= rec.days_until_expiry %> gg)</span>
<% end %>
<% else %>
<% end %>
</td>
<td><%= rec.status_label %></td>
<td>
<%= form_with url: public_club_recording_path(@club, rec), method: :patch, local: true do %>
<%= hidden_field_tag :team_id, @filter_team_id if @filter_team_id.present? %>
<%= hidden_field_tag :status, @filter_status if @filter_status.present? %>
<%= select_tag "recording[privacy_status]",
options_for_select([["Pubblico", "public"], ["Privato", "unlisted"]], rec.privacy_status),
options_for_select([["Pubblico", "public"], ["Privato (link)", "unlisted"]], rec.privacy_status),
onchange: "this.form.submit()" %>
<% end %>
</td>
<td style="white-space:nowrap;font-size:0.8rem">
<% if rec.replay_url %>
<%= link_to "Guarda", rec.replay_url, target: "_blank", rel: "noopener" %>
<%= link_to "Sito", rec.replay_url, target: "_blank", rel: "noopener" %>
<% end %>
<% if rec.team.entitlements.phone_download_enabled? && rec.ready? %>
· <%= link_to "MP4", public_replay_download_path(rec.stream_session_id) %>
@@ -77,8 +117,9 @@
· <%= link_to "YT", rec.youtube_watch_url, target: "_blank", rel: "noopener" %>
<% end %>
· <%= button_to "Elimina", public_club_recording_path(@club, rec), method: :delete,
params: { team_id: @filter_team_id, status: @filter_status }.compact,
class: "btn-link", style: "display:inline;padding:0;border:0;background:none;color:#e53935;cursor:pointer",
form: { data: { turbo_confirm: "Eliminare definitivamente questo replay?" } } %>
form: { data: { turbo_confirm: "Eliminare definitivamente questo replay? Verranno rimossi i file sul server e il video YouTube collegato." } } %>
</td>
</tr>
<% end %>

View File

@@ -67,6 +67,9 @@
<strong class="replay-card__title"><%= rec.title_or_default %></strong>
<p class="replay-card__meta">
<%= match.team.club.name %> · <%= l_local(rec.recorded_at_or_fallback) %>
<% if rec.source_platform_label != "—" %>
· <span class="badge badge-wait" style="font-size:0.65rem;vertical-align:middle"><%= rec.source_platform_label %></span>
<% end %>
</p>
<p class="replay-card__meta replay-card__meta--sub">
<%= rec.views_label %>

View File

@@ -10,6 +10,9 @@
<p class="replay-show__meta">
<%= @match.team.club.name %> · <%= l_local(@recording.recorded_at_or_fallback) %>
· <%= @recording.views_label %>
<% if @recording.source_platform_label != "—" %>
· <%= @recording.source_platform_label %>
<% end %>
</p>
</header>
@@ -46,6 +49,22 @@
<% end %>
</div>
</div>
<% elsif @recording.ready? && @recording.youtube_watch_url.present? %>
<div class="replay-show__player card">
<div class="replay-video" style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;background:#000;border-radius:12px">
<iframe src="https://www.youtube.com/embed/<%= @recording.youtube_video_id %>"
title="<%= @recording.title_or_default %>"
style="position:absolute;top:0;left:0;width:100%;height:100%;border:0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>
</div>
</div>
<div class="replay-show__details card">
<p class="replay-show__details-line">
Replay disponibile su YouTube (copia server non presente).
<%= link_to "Apri su YouTube", @recording.youtube_watch_url, target: "_blank", rel: "noopener" %>
</p>
</div>
<% elsif @recording.ready? %>
<div class="card replay-show__status">
<p>Il file video non è più disponibile (archivio rimosso o in migrazione).</p>