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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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"
|
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"
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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?,
|
||||||
|
|||||||
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" %>
|
<%= 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 %>
|
||||||
|
|||||||
@@ -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 %>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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")
|
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
|
||||||
|
|||||||
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
|
||||||
@@ -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 dall’admin società.
|
- **Pubblico** (`public`): compare in `/replay`, indicizzabile, YouTube `public`
|
||||||
|
- **Privato** (`unlisted`): non in catalogo pubblico, accessibile solo con link diretto, `noindex`, YouTube `unlisted`
|
||||||
|
|
||||||
|
Modificabile dall’archivio 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 l’abbonamento 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
|
||||||
|
|||||||
Reference in New Issue
Block a user