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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
17
backend/app/jobs/recordings/sync_youtube_privacy_job.rb
Normal file
17
backend/app/jobs/recordings/sync_youtube_privacy_job.rb
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?,
|
||||
|
||||
66
backend/app/services/youtube/video_lifecycle_service.rb
Normal file
66
backend/app/services/youtube/video_lifecycle_service.rb
Normal 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
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
38
backend/spec/services/recordings/delete_spec.rb
Normal file
38
backend/spec/services/recordings/delete_spec.rb
Normal 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
|
||||
@@ -32,4 +32,16 @@ RSpec.describe Recordings::FinalizeSession do
|
||||
Billing::AssignPlan.call(club: club, plan_slug: "free")
|
||||
expect(described_class.new(session).call).to be_nil
|
||||
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
|
||||
|
||||
41
backend/spec/services/teams/entitlements_recordings_spec.rb
Normal file
41
backend/spec/services/teams/entitlements_recordings_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user