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 def update
authorize_manage! 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) render json: recording_json(@recording)
end end
@@ -96,7 +99,10 @@ module Api
youtube_video_id: recording.youtube_video_id, youtube_video_id: recording.youtube_video_id,
youtube_watch_url: recording.youtube_watch_url, youtube_watch_url: recording.youtube_watch_url,
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?, 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, expires_at: recording.expires_at,
days_until_expiry: recording.days_until_expiry,
metadata: recording.metadata metadata: recording.metadata
} }
end end

View File

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

View File

@@ -2,7 +2,7 @@ module Public
class ClubRecordingsController < WebBaseController class ClubRecordingsController < WebBaseController
before_action :require_login! before_action :require_login!
before_action :set_club 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] before_action :set_recording, only: %i[update destroy publish_youtube]
def index def index
@@ -11,17 +11,39 @@ module Public
.not_deleted .not_deleted
.includes(stream_session: { match: :team }) .includes(stream_session: { match: :team })
.order(recorded_at: :desc, created_at: :desc) .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) @teams = @club.teams.order(:name)
@filter_team_id = params[:team_id]
@filter_status = params[:status]
end end
def update def update
privacy = params.require(:recording).permit(:privacy_status, :title)[:privacy_status] permitted = params.require(:recording).permit(:privacy_status, :title)
attrs = {} attrs = {}
privacy = permitted[:privacy_status]
attrs[:privacy_status] = privacy if privacy.in?(Recording::PRIVACY_STATUSES) attrs[:privacy_status] = privacy if privacy.in?(Recording::PRIVACY_STATUSES)
title = params.dig(:recording, :title) title = permitted[:title]
attrs[:title] = title if title.present? attrs[:title] = title if title.present?
privacy_changed = attrs.key?(:privacy_status) && attrs[:privacy_status] != @recording.privacy_status
@recording.update!(attrs) @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 rescue ActiveRecord::RecordInvalid => e
redirect_to public_club_recordings_path(@club), alert: e.record.errors.full_messages.join(", ") redirect_to public_club_recordings_path(@club), alert: e.record.errors.full_messages.join(", ")
end end
@@ -45,11 +67,21 @@ module Public
private private
def set_club 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 end
def set_recording def set_recording
@recording = Recording.for_club(@club).not_deleted.find(params[:id]) @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 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" count == 1 ? "1 visualizzazione" : "#{count} visualizzazioni"
end 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 def status_label
return "Eliminato" if deleted? return "Eliminato" if deleted?
return "Scaduto" if status == "expired" return "Scaduto" if status == "expired"

View File

@@ -6,6 +6,7 @@ module Recordings
end end
def call def call
delete_youtube_video
delete_storage_object if @recording.storage_key.present? delete_storage_object if @recording.storage_key.present?
cleanup_local_artifacts cleanup_local_artifacts
@@ -13,13 +14,23 @@ module Recordings
status: "expired", status: "expired",
deleted_at: Time.current, deleted_at: Time.current,
storage_key: nil, storage_key: nil,
thumbnail_storage_key: nil thumbnail_storage_key: nil,
youtube_video_id: nil,
youtube_published_at: nil
) )
@recording @recording
end end
private 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 def delete_storage_object
storage = Recordings::Storage.new storage = Recordings::Storage.new
storage.delete(key: @recording.storage_key) if @recording.storage_key.present? storage.delete(key: @recording.storage_key) if @recording.storage_key.present?

View File

@@ -6,7 +6,7 @@ module Recordings
def call def call
team = @session.match.team 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 retention_days = team.entitlements.recording_retention_days
expires_at = retention_days.positive? ? retention_days.days.from_now : nil expires_at = retention_days.positive? ? retention_days.days.from_now : nil

View File

@@ -5,6 +5,8 @@ require "aws-sdk-s3"
module Recordings module Recordings
# Serve file replay/thumbnail al browser senza esporre URL S3 interni (es. http://garage:3900). # Serve file replay/thumbnail al browser senza esporre URL S3 interni (es. http://garage:3900).
class ServeFromStorage class ServeFromStorage
CHUNK_SIZE = 256 * 1024
def initialize(key:, content_type:, disposition: "inline", filename: nil) def initialize(key:, content_type:, disposition: "inline", filename: nil)
@key = key @key = key
@content_type = content_type @content_type = content_type
@@ -14,7 +16,15 @@ module Recordings
def call(controller) def call(controller)
path = local_path 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) proxy_s3(controller)
end end
@@ -41,22 +51,24 @@ module Recordings
controller.response.headers["Accept-Ranges"] = "bytes" controller.response.headers["Accept-Ranges"] = "bytes"
controller.response.headers["Content-Length"] = resp.content_length.to_s if resp.content_length 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-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? 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 end
def s3_client def s3_client
@@ -66,7 +78,6 @@ module Recordings
endpoint: MatchLiveTv.replay_storage_endpoint, endpoint: MatchLiveTv.replay_storage_endpoint,
region: MatchLiveTv.replay_storage_region, region: MatchLiveTv.replay_storage_region,
force_path_style: MatchLiveTv.replay_storage_force_path_style?, 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" response_checksum_validation: "WHEN_REQUIRED"
) )
end end

View File

@@ -178,12 +178,18 @@ module Teams
assert_can_stream_on!("youtube") assert_can_stream_on!("youtube")
end end
def can_access_recordings? # Nuove registrazioni (MediaMTX + FinalizeSession): richiede abbonamento attivo.
def can_create_recordings?
active? && plan.recordings_enabled? active? && plan.recordings_enabled?
end 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? def recording_enabled_for_mediamtx?
can_access_recordings? can_create_recordings?
end end
def recording_retention_days def recording_retention_days
@@ -215,6 +221,7 @@ module Teams
concurrent_streams_used: concurrent_streams_used, concurrent_streams_used: concurrent_streams_used,
concurrent_streams_limit: concurrent_streams_limit, concurrent_streams_limit: concurrent_streams_limit,
recordings_enabled: can_access_recordings?, recordings_enabled: can_access_recordings?,
can_create_recordings: can_create_recordings?,
recording_retention_days: recording_retention_days, recording_retention_days: recording_retention_days,
phone_download_enabled: phone_download_enabled?, phone_download_enabled: phone_download_enabled?,
youtube_enabled: youtube_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" %> <%= link_to "← #{@club.name}", public_club_path(@club), class: "back-link" %>
<h1>Archivio Replay</h1> <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="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"> <div class="card" style="padding:16px">
@@ -26,6 +30,23 @@
</div> </div>
</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? %> <% if @recordings.any? %>
<div class="card"> <div class="card">
<table class="data"> <table class="data">
@@ -33,6 +54,7 @@
<tr> <tr>
<th></th> <th></th>
<th>Titolo</th> <th>Titolo</th>
<th>Piattaforma</th>
<th>Data</th> <th>Data</th>
<th>Durata</th> <th>Durata</th>
<th>Views</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"> <img src="<%= rec.thumbnail_url %>" alt="" width="64" height="36" style="object-fit:cover;border-radius:4px">
<% end %> <% end %>
</td> </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><%= l_local(rec.recorded_at_or_fallback) %></td>
<td><%= rec.duration_label %></td> <td><%= rec.duration_label %></td>
<td><%= rec.view_count %></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><%= rec.status_label %></td>
<td> <td>
<%= form_with url: public_club_recording_path(@club, rec), method: :patch, local: true do %> <%= 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]", <%= 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()" %> onchange: "this.form.submit()" %>
<% end %> <% end %>
</td> </td>
<td style="white-space:nowrap;font-size:0.8rem"> <td style="white-space:nowrap;font-size:0.8rem">
<% if rec.replay_url %> <% 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 %> <% end %>
<% if rec.team.entitlements.phone_download_enabled? && rec.ready? %> <% if rec.team.entitlements.phone_download_enabled? && rec.ready? %>
· <%= link_to "MP4", public_replay_download_path(rec.stream_session_id) %> · <%= 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" %> · <%= link_to "YT", rec.youtube_watch_url, target: "_blank", rel: "noopener" %>
<% end %> <% end %>
· <%= button_to "Elimina", public_club_recording_path(@club, rec), method: :delete, · <%= 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", 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> </td>
</tr> </tr>
<% end %> <% end %>

View File

@@ -67,6 +67,9 @@
<strong class="replay-card__title"><%= rec.title_or_default %></strong> <strong class="replay-card__title"><%= rec.title_or_default %></strong>
<p class="replay-card__meta"> <p class="replay-card__meta">
<%= match.team.club.name %> · <%= l_local(rec.recorded_at_or_fallback) %> <%= 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>
<p class="replay-card__meta replay-card__meta--sub"> <p class="replay-card__meta replay-card__meta--sub">
<%= rec.views_label %> <%= rec.views_label %>

View File

@@ -10,6 +10,9 @@
<p class="replay-show__meta"> <p class="replay-show__meta">
<%= @match.team.club.name %> · <%= l_local(@recording.recorded_at_or_fallback) %> <%= @match.team.club.name %> · <%= l_local(@recording.recorded_at_or_fallback) %>
· <%= @recording.views_label %> · <%= @recording.views_label %>
<% if @recording.source_platform_label != "—" %>
· <%= @recording.source_platform_label %>
<% end %>
</p> </p>
</header> </header>
@@ -46,6 +49,22 @@
<% end %> <% end %>
</div> </div>
</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? %> <% elsif @recording.ready? %>
<div class="card replay-show__status"> <div class="card replay-show__status">
<p>Il file video non è più disponibile (archivio rimosso o in migrazione).</p> <p>Il file video non è più disponibile (archivio rimosso o in migrazione).</p>

View File

@@ -0,0 +1,38 @@
require "rails_helper"
RSpec.describe Recordings::Delete do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let!(:session) do
StreamSession.create!(match: match, user: user, platform: "youtube", status: "ended")
end
let!(:recording) do
Recording.create!(
stream_session: session,
team: team,
status: "ready",
privacy_status: "public",
expires_at: 10.days.from_now,
storage_key: "teams/#{team.id}/sessions/#{session.id}/replay.mp4",
youtube_video_id: "abc123xyz"
)
end
it "elimina storage e revoca youtube_video_id" do
storage = instance_double(Recordings::Storage, delete: true)
allow(Recordings::Storage).to receive(:new).and_return(storage)
yt = instance_double(Youtube::VideoLifecycleService, delete!: true)
allow(Youtube::VideoLifecycleService).to receive(:new).with(recording).and_return(yt)
described_class.new(recording).call
expect(yt).to have_received(:delete!)
expect(storage).to have_received(:delete).at_least(:once)
recording.reload
expect(recording.deleted_at).to be_present
expect(recording.youtube_video_id).to be_nil
expect(recording.storage_key).to be_nil
end
end

View File

@@ -32,4 +32,16 @@ RSpec.describe Recordings::FinalizeSession do
Billing::AssignPlan.call(club: club, plan_slug: "free") Billing::AssignPlan.call(club: club, plan_slug: "free")
expect(described_class.new(session).call).to be_nil expect(described_class.new(session).call).to be_nil
end end
it "imposta retention 90 giorni con premium full" do
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
rec = described_class.new(session).call
expect(rec.expires_at).to be > 89.days.from_now
end
it "non crea recording se abbonamento scaduto" do
Billing::AssignPlan.call(club: club, plan_slug: "premium_light")
club.subscription.update!(status: "canceled")
expect(described_class.new(session).call).to be_nil
end
end end

View File

@@ -0,0 +1,41 @@
require "rails_helper"
RSpec.describe Teams::Entitlements, "recordings" do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let!(:session) do
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended")
end
before { load Rails.root.join("db/seeds/plans.rb") }
it "premium attivo può creare registrazioni" do
Billing::AssignPlan.call(club: club, plan_slug: "premium_light")
ent = described_class.new(team)
expect(ent.can_create_recordings?).to be true
expect(ent.can_access_recordings?).to be true
end
it "abbonamento scaduto non crea ma accede all'archivio esistente" do
Billing::AssignPlan.call(club: club, plan_slug: "premium_light")
club.subscription.update!(status: "canceled")
Recording.create!(
stream_session: session,
team: team,
status: "ready",
privacy_status: "unlisted",
expires_at: 5.days.from_now,
storage_key: "k"
)
ent = described_class.new(team.reload)
expect(ent.can_create_recordings?).to be false
expect(ent.can_access_recordings?).to be true
end
it "premium full ha retention 90 giorni" do
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
expect(described_class.new(team).recording_retention_days).to eq(90)
end
end

View File

@@ -32,10 +32,15 @@ MediaMTX registra **solo mentre il telefono pubblica** (RTMP connesso). In pausa
- Contatore visualizzazioni su ogni play (`view_count`) - Contatore visualizzazioni su ogni play (`view_count`)
### 5. Visibilità ### 5. Visibilità
Pubblico / Privato (unlisted), modificabile dalladmin società. - **Pubblico** (`public`): compare in `/replay`, indicizzabile, YouTube `public`
- **Privato** (`unlisted`): non in catalogo pubblico, accessibile solo con link diretto, `noindex`, YouTube `unlisted`
Modificabile dallarchivio società (`/clubs/:id/replays`). Il cambio privacy sincronizza automaticamente il VOD YouTube se presente (`Recordings::SyncYoutubePrivacyJob`).
### 6. Eliminazione anticipata ### 6. Eliminazione anticipata
`Recordings::Delete`DB + video + thumbnail su storage. `Recordings::Delete`elimina video YouTube collegato, oggetti S3/Garage (MP4 + thumbnail) e soft-delete DB.
Stesso comportamento alla scadenza retention (`PurgeExpiredJob` alle 03:00).
### 7. Dashboard KPI ### 7. Dashboard KPI
Replay disponibili, in scadenza (7 gg), spazio occupato, **visualizzazioni totali**. Replay disponibili, in scadenza (7 gg), spazio occupato, **visualizzazioni totali**.
@@ -115,6 +120,22 @@ Per ridimensionare dopo:
bash scripts/garage_set_capacity.sh 85G # oppure senza argomento: calcolo automatico bash scripts/garage_set_capacity.sh 85G # oppure senza argomento: calcolo automatico
``` ```
## Piattaforme live e replay
| Origine diretta | Copia Garage/S3 | Player sito `/replay/:id` | YouTube VOD |
|-----------------|-----------------|----------------------------|-------------|
| `matchlivetv` | Sì (Premium) | MP4 via proxy Rails | Manuale (Premium Full) |
| `youtube` | Sì (Premium) | MP4 via proxy Rails | Auto se Premium Full + diretta YouTube |
Se il file S3 non è disponibile ma esiste `youtube_video_id`, la pagina replay mostra embed YouTube.
## Retention e abbonamento
- `expires_at` viene impostato alla fine diretta: **30 giorni** (Premium Light), **90 giorni** (Premium Full)
- `expires_at` **non cambia** al cambio piano successivo
- **Nuove registrazioni** richiedono abbonamento attivo (`can_create_recordings?`)
- **Archivio esistente** resta accessibile fino a `expires_at` anche se labbonamento scade (`can_access_recordings?`)
## API mobile ## API mobile
- `GET /teams/:id/recordings` — lista con thumbnail, views, download_enabled - `GET /teams/:id/recordings` — lista con thumbnail, views, download_enabled