diff --git a/backend/app/controllers/api/v1/recordings_controller.rb b/backend/app/controllers/api/v1/recordings_controller.rb index b10e995..f673978 100644 --- a/backend/app/controllers/api/v1/recordings_controller.rb +++ b/backend/app/controllers/api/v1/recordings_controller.rb @@ -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 diff --git a/backend/app/controllers/api/v1/teams_controller.rb b/backend/app/controllers/api/v1/teams_controller.rb index f1fab7d..f3b8b4d 100644 --- a/backend/app/controllers/api/v1/teams_controller.rb +++ b/backend/app/controllers/api/v1/teams_controller.rb @@ -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 diff --git a/backend/app/controllers/public/club_recordings_controller.rb b/backend/app/controllers/public/club_recordings_controller.rb index 07dc7d0..3d4f770 100644 --- a/backend/app/controllers/public/club_recordings_controller.rb +++ b/backend/app/controllers/public/club_recordings_controller.rb @@ -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 diff --git a/backend/app/jobs/recordings/sync_youtube_privacy_job.rb b/backend/app/jobs/recordings/sync_youtube_privacy_job.rb new file mode 100644 index 0000000..8673ba1 --- /dev/null +++ b/backend/app/jobs/recordings/sync_youtube_privacy_job.rb @@ -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 diff --git a/backend/app/models/recording.rb b/backend/app/models/recording.rb index 51dec84..7ba9483 100644 --- a/backend/app/models/recording.rb +++ b/backend/app/models/recording.rb @@ -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" diff --git a/backend/app/services/recordings/delete.rb b/backend/app/services/recordings/delete.rb index d01c6a9..e31a4db 100644 --- a/backend/app/services/recordings/delete.rb +++ b/backend/app/services/recordings/delete.rb @@ -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? diff --git a/backend/app/services/recordings/finalize_session.rb b/backend/app/services/recordings/finalize_session.rb index 826435d..2e7bb7a 100644 --- a/backend/app/services/recordings/finalize_session.rb +++ b/backend/app/services/recordings/finalize_session.rb @@ -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 diff --git a/backend/app/services/recordings/serve_from_storage.rb b/backend/app/services/recordings/serve_from_storage.rb index 3fd179c..bccb2c5 100644 --- a/backend/app/services/recordings/serve_from_storage.rb +++ b/backend/app/services/recordings/serve_from_storage.rb @@ -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 diff --git a/backend/app/services/teams/entitlements.rb b/backend/app/services/teams/entitlements.rb index 41b2c61..b656672 100644 --- a/backend/app/services/teams/entitlements.rb +++ b/backend/app/services/teams/entitlements.rb @@ -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?, diff --git a/backend/app/services/youtube/video_lifecycle_service.rb b/backend/app/services/youtube/video_lifecycle_service.rb new file mode 100644 index 0000000..a926851 --- /dev/null +++ b/backend/app/services/youtube/video_lifecycle_service.rb @@ -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 diff --git a/backend/app/views/public/club_recordings/index.html.erb b/backend/app/views/public/club_recordings/index.html.erb index 934d53d..6ab75f4 100644 --- a/backend/app/views/public/club_recordings/index.html.erb +++ b/backend/app/views/public/club_recordings/index.html.erb @@ -5,7 +5,11 @@ <%= link_to "← #{@club.name}", public_club_path(@club), class: "back-link" %>
Gestisci registrazioni, visibilità, download e pubblicazione YouTube.
+
+ Gestisci registrazioni, visibilità e download.
+ Privato = link non indicizzato, non compare in /replay pubblico.
+ Eliminando un replay vengono rimossi anche i file sul server e il video YouTube collegato.
+
| Titolo | +Piattaforma | Data | Durata | Views | @@ -50,22 +72,40 @@<%= rec.title_or_default %> | ++ <%= 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 %> + | +<%= rec.source_platform_label %> | <%= l_local(rec.recorded_at_or_fallback) %> | <%= rec.duration_label %> | <%= rec.view_count %> | -<%= rec.expires_at ? l_local(rec.expires_at) : "—" %> | +
+ <% if rec.expires_at %>
+ <%= l_local(rec.expires_at) %>
+ <% if rec.days_until_expiry && rec.days_until_expiry.positive? %>
+ (<%= rec.days_until_expiry %> gg) + <% end %> + <% else %> + — + <% end %> + |
<%= rec.status_label %> | <%= 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 %> | <% 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." } } %> |
|---|