diff --git a/.gitignore b/.gitignore index ac7cba2..668175a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ infra/certs/ infra/recordings/ infra/.env infra/.env.production.generated +infra/garage/dev-credentials.env # OS .DS_Store diff --git a/backend/Gemfile b/backend/Gemfile index fa5a506..72e372b 100644 --- a/backend/Gemfile +++ b/backend/Gemfile @@ -17,6 +17,7 @@ gem "attr_encrypted" gem "google-apis-youtube_v3" gem "faraday" gem "stripe" +gem "aws-sdk-s3", "~> 1.0", require: false gem "kamal", require: false group :development, :test do diff --git a/backend/Gemfile.lock b/backend/Gemfile.lock index 2864ced..3b485a4 100644 --- a/backend/Gemfile.lock +++ b/backend/Gemfile.lock @@ -81,6 +81,25 @@ GEM ast (2.4.3) attr_encrypted (4.2.0) encryptor (~> 3.0.0) + aws-eventstream (1.4.0) + aws-partitions (1.1256.0) + aws-sdk-core (3.251.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.129.0) + aws-sdk-core (~> 3, >= 3.248.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.225.0) + aws-sdk-core (~> 3, >= 3.248.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) bcrypt (3.1.22) bcrypt_pbkdf (1.1.2) @@ -158,6 +177,7 @@ GEM prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) + jmespath (1.6.2) json (2.19.5) jwt (3.2.0) base64 @@ -416,6 +436,7 @@ PLATFORMS DEPENDENCIES aasm attr_encrypted + aws-sdk-s3 (~> 1.0) bcrypt (~> 3.1.7) bootsnap brakeman diff --git a/backend/app/controllers/admin/clubs_controller.rb b/backend/app/controllers/admin/clubs_controller.rb index e68a26e..09c8d52 100644 --- a/backend/app/controllers/admin/clubs_controller.rb +++ b/backend/app/controllers/admin/clubs_controller.rb @@ -10,7 +10,7 @@ module Admin def show @subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active") @plans = Plan.ordered.reject { |p| p.slug == "free" } - @teams = @club.teams.includes(:youtube_credential).order(:name) + @teams = @club.teams.order(:name) end def grant_comped diff --git a/backend/app/controllers/api/v1/recordings_controller.rb b/backend/app/controllers/api/v1/recordings_controller.rb new file mode 100644 index 0000000..b10e995 --- /dev/null +++ b/backend/app/controllers/api/v1/recordings_controller.rb @@ -0,0 +1,105 @@ +module Api + module V1 + class RecordingsController < BaseController + before_action :set_recording + + def update + authorize_manage! + @recording.update!(recording_update_attrs) + render json: recording_json(@recording) + end + + def destroy + authorize_manage! + Recordings::Delete.new(@recording).call + head :no_content + end + + def download + url = Recordings::DownloadUrl.new(@recording, viewer: current_user).call + render json: { download_url: url, expires_in_seconds: MatchLiveTv.replay_download_url_ttl_seconds } + rescue Recordings::DownloadUrl::NotAllowed + render json: { error: "Download non disponibile per il tuo piano", error_code: "download_not_allowed" }, + status: :forbidden + rescue ArgumentError => e + render json: { error: e.message }, status: :unprocessable_entity + end + + def publish_youtube + authorize_manage! + ent = @recording.team.entitlements + unless ent.premium_full? && ent.youtube_enabled? + return render json: { error: "Republicazione YouTube con Premium Full" }, status: :forbidden + end + + Recordings::PublishToYoutubeJob.perform_async(@recording.id) + render json: { queued: true, message: "Pubblicazione su YouTube in corso" } + end + + private + + def set_recording + team_ids = current_user.manageable_teams.map(&:id) + @recording = Recording.not_deleted + .includes(stream_session: :match) + .where(team_id: team_ids) + .find(params[:id]) + end + + def authorize_manage! + team = @recording.team + return if team.club.owned_by?(current_user) + return if current_user.manageable_teams.exists?(id: team.id) + + render json: { error: "Non autorizzato" }, status: :forbidden + end + + def recording_update_attrs + p = params.require(:recording).permit(:privacy_status, :title) + p = p.to_h + p[:privacy_status] = "unlisted" if p[:privacy_status] == "private" + if params.dig(:recording, :metadata).present? + p[:metadata] = @recording.metadata.merge( + params[:recording][:metadata].to_unsafe_h.stringify_keys + ) + end + p + end + + def recording_json(recording) + session = recording.stream_session + match = session.match + ent = recording.team.entitlements + { + id: recording.id, + session_id: session.id, + match_id: match.id, + title: recording.title_or_default, + opponent_name: match.opponent_name, + team_name: match.team.name, + team_id: recording.team_id, + status: recording.status, + status_label: recording.status_label, + privacy_status: recording.privacy_status, + ended_at: session.ended_at, + recorded_at: recording.recorded_at_or_fallback, + duration_secs: recording.duration_secs, + duration_label: recording.duration_label, + byte_size: recording.byte_size, + byte_size_label: recording.byte_size_label, + view_count: recording.view_count, + views_label: recording.views_label, + replay_url: recording.replay_url, + playback_url: recording.playback_stream_url, + thumbnail_url: recording.thumbnail_url, + download_enabled: ent.phone_download_enabled?, + youtube_video_id: recording.youtube_video_id, + youtube_watch_url: recording.youtube_watch_url, + youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?, + expires_at: recording.expires_at, + metadata: recording.metadata + } + end + end + end +end diff --git a/backend/app/controllers/api/v1/teams_controller.rb b/backend/app/controllers/api/v1/teams_controller.rb index 4eb9d56..e4d0f41 100644 --- a/backend/app/controllers/api/v1/teams_controller.rb +++ b/backend/app/controllers/api/v1/teams_controller.rb @@ -37,7 +37,11 @@ module Api }, status: :forbidden end - recs = team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(50) + recs = team.recordings.not_deleted + .where(status: %w[ready processing]) + .includes(stream_session: :match) + .order(recorded_at: :desc, created_at: :desc) + .limit(50) render json: recs.map { |r| recording_json(r) } end @@ -87,7 +91,7 @@ module Api youtube_uses_platform_channel: yt.uses_platform_channel?, youtube_platform_available: resolver.platform_available?, youtube_team_channel_available: resolver.team_channel_available?, - youtube_team_channel_title: team.youtube_credential&.channel_title, + youtube_team_channel_title: team.club.youtube_credential&.channel_title, plan_slug: ent.plan.slug, plan_name: ent.plan.name, premium_active: ent.premium_active?, @@ -108,7 +112,7 @@ module Api youtube_mode: ent.plan.youtube_mode, staff_role: current_user.staff_role_for(team), can_stream: current_user.can_stream_for?(team), - can_connect_youtube: current_user.can_stream_for?(team) && ent.premium_full? && ent.youtube_enabled? + can_connect_youtube: team.club.owned_by?(current_user) && ent.premium_full? && ent.youtube_enabled? } if detail data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut| @@ -121,16 +125,34 @@ module Api def recording_json(recording) session = recording.stream_session match = session.match + ent = recording.team.entitlements { id: recording.id, session_id: session.id, match_id: match.id, + title: recording.title_or_default, opponent_name: match.opponent_name, team_name: match.team.name, + status: recording.status, + status_label: recording.status_label, + privacy_status: recording.privacy_status, ended_at: session.ended_at, + recorded_at: recording.recorded_at_or_fallback, + duration_secs: recording.duration_secs, + duration_label: recording.duration_label, + byte_size: recording.byte_size, + byte_size_label: recording.byte_size_label, replay_url: recording.replay_url, - download_url: recording.replay_url, - expires_at: recording.expires_at + playback_url: recording.playback_stream_url, + thumbnail_url: recording.thumbnail_url, + download_enabled: ent.phone_download_enabled?, + view_count: recording.view_count, + views_label: recording.views_label, + youtube_video_id: recording.youtube_video_id, + youtube_watch_url: recording.youtube_watch_url, + youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?, + expires_at: recording.expires_at, + metadata: recording.metadata } end end diff --git a/backend/app/controllers/api/v1/youtube_controller.rb b/backend/app/controllers/api/v1/youtube_controller.rb index 4f3d4f0..58ef768 100644 --- a/backend/app/controllers/api/v1/youtube_controller.rb +++ b/backend/app/controllers/api/v1/youtube_controller.rb @@ -4,6 +4,9 @@ module Api def authorize team = current_user.streamable_teams.find { |t| t.id.to_s == params[:team_id].to_s } raise ActiveRecord::RecordNotFound unless team + unless team.club.owned_by?(current_user) + return render json: { error: "Solo il titolare della società può collegare YouTube" }, status: :forbidden + end team.entitlements.assert_can_connect_youtube! return_app = ActiveModel::Type::Boolean.new.cast(params[:return_app]) diff --git a/backend/app/controllers/public/club_recordings_controller.rb b/backend/app/controllers/public/club_recordings_controller.rb new file mode 100644 index 0000000..07dc7d0 --- /dev/null +++ b/backend/app/controllers/public/club_recordings_controller.rb @@ -0,0 +1,55 @@ +module Public + class ClubRecordingsController < WebBaseController + before_action :require_login! + before_action :set_club + before_action -> { require_club_owner!(@club) } + before_action :set_recording, only: %i[update destroy publish_youtube] + + def index + @stats = Recordings::ClubStats.new(@club).call + @recordings = Recording.for_club(@club) + .not_deleted + .includes(stream_session: { match: :team }) + .order(recorded_at: :desc, created_at: :desc) + @teams = @club.teams.order(:name) + end + + def update + privacy = params.require(:recording).permit(:privacy_status, :title)[:privacy_status] + attrs = {} + attrs[:privacy_status] = privacy if privacy.in?(Recording::PRIVACY_STATUSES) + title = params.dig(:recording, :title) + attrs[:title] = title if title.present? + @recording.update!(attrs) + redirect_to public_club_recordings_path(@club), notice: "Replay aggiornato" + rescue ActiveRecord::RecordInvalid => e + redirect_to public_club_recordings_path(@club), alert: e.record.errors.full_messages.join(", ") + end + + def destroy + Recordings::Delete.new(@recording).call + redirect_to public_club_recordings_path(@club), notice: "Replay eliminato" + end + + def publish_youtube + ent = @recording.team.entitlements + unless ent.premium_full? && ent.youtube_enabled? + redirect_to public_club_recordings_path(@club), alert: "Republicazione YouTube con Premium Full" + return + end + + Recordings::PublishToYoutubeJob.perform_async(@recording.id) + redirect_to public_club_recordings_path(@club), notice: "Pubblicazione su YouTube avviata" + end + + private + + def set_club + @club = current_user.owned_clubs.find(params[:club_id]) + end + + def set_recording + @recording = Recording.for_club(@club).not_deleted.find(params[:id]) + end + end +end diff --git a/backend/app/controllers/public/clubs_controller.rb b/backend/app/controllers/public/clubs_controller.rb index 971933e..5c3bf6a 100644 --- a/backend/app/controllers/public/clubs_controller.rb +++ b/backend/app/controllers/public/clubs_controller.rb @@ -3,7 +3,7 @@ module Public include BrandingAttachments before_action :require_login! - before_action :set_club, only: %i[show edit update billing checkout portal] + before_action :set_club, only: %i[show edit update billing checkout portal youtube_connect youtube_disconnect] before_action :require_billing_profile_for_premium_checkout!, only: :checkout def new @@ -54,6 +54,7 @@ module Public @entitlements_team = @club.teams.first @entitlements = @entitlements_team&.entitlements @teams = @club.teams.order(:name) + @replay_stats = Recordings::ClubStats.new(@club).call if @entitlements&.can_access_recordings? end def edit @@ -136,6 +137,33 @@ module Public redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}" end + def youtube_connect + require_club_owner!(@club) + entitlements = @club.teams.first!.entitlements + entitlements.assert_can_connect_youtube! + + if ENV["YOUTUBE_CLIENT_ID"].blank? + redirect_to public_club_path(@club), alert: "YouTube OAuth non configurato sul server" + return + end + + return_app = params[:return_app] == "1" + state = Youtube::OauthState.for_club( + club_id: @club.id, + user_id: current_user.id, + return_app: return_app + ) + redirect_to Youtube::OauthUrl.build(state: state), allow_other_host: true + rescue Teams::EntitlementError => e + redirect_to public_club_path(@club), alert: e.message + end + + def youtube_disconnect + require_club_owner!(@club) + @club.youtube_credential&.destroy! + redirect_to public_club_path(@club), notice: "Canale YouTube della società scollegato" + end + def portal require_club_owner!(@club) unless MatchLiveTv.stripe_enabled? diff --git a/backend/app/controllers/public/replay_controller.rb b/backend/app/controllers/public/replay_controller.rb index bc4c252..72184ab 100644 --- a/backend/app/controllers/public/replay_controller.rb +++ b/backend/app/controllers/public/replay_controller.rb @@ -2,14 +2,102 @@ module Public class ReplayController < SiteBaseController layout "marketing_live" - def show - @session = StreamSession.includes(match: :team).find(params[:id]) - @recording = Recording.ready.find_by(stream_session: @session) - @match = @session.match + before_action :set_session_and_recording, only: %i[show stream thumbnail download] + before_action :authorize_replay_access!, only: %i[show stream thumbnail download] - unless @recording - redirect_to public_live_path(@session), alert: "Replay non disponibile" + def index + @query = params[:q].to_s.strip + @recordings = Recording.ready.publicly_listed + .includes(stream_session: { match: { team: :club } }) + .search_replays(@query) + .order(recorded_at: :desc, created_at: :desc) + .limit(100) + if params[:club_id].present? + @recordings = @recordings.joins(:team).where(teams: { club_id: params[:club_id] }) end + if params[:team_id].present? + @recordings = @recordings.where(team_id: params[:team_id]) + end + @clubs = Club.joins(teams: :recordings) + .merge(Recording.ready.publicly_listed) + .distinct + .order(:name) + @filter_club = @clubs.find { |c| c.id.to_s == params[:club_id].to_s } if params[:club_id].present? + end + + def show + @match = @session.match + end + + def stream + Recordings::IncrementViews.new(@recording).call + serve_video(disposition: "inline") + end + + def thumbnail + serve_thumbnail + end + + def download + unless download_allowed? + redirect_to public_replay_path(@session), alert: "Download non disponibile" + return + end + + serve_video(disposition: "attachment") + end + + private + + def serve_video(disposition:) + Recordings::ServeFromStorage.new( + key: @recording.storage_key, + content_type: "video/mp4", + disposition: disposition, + filename: disposition == "attachment" ? download_filename : nil + ).call(self) + rescue ArgumentError + head :not_found + end + + def serve_thumbnail + key = @recording.thumbnail_storage_key + return head :not_found if key.blank? + + Recordings::ServeFromStorage.new( + key: key, + content_type: "image/jpeg", + disposition: "inline" + ).call(self) + rescue ArgumentError + head :not_found + end + + def download_allowed? + ent = @recording.team.entitlements + return false unless ent.phone_download_enabled? + + Recordings::AccessPolicy.new(@recording, viewer: current_user).allowed? || + Recordings::AccessPolicy.new(@recording, viewer: current_user).manage_allowed? + end + + def download_filename + slug = @recording.title_or_default.parameterize.presence || "replay" + "#{slug}.mp4" + end + + def set_session_and_recording + @session = StreamSession.includes(match: { team: :club }).find(params[:id]) + @recording = Recording.not_deleted.find_by(stream_session: @session) + return if @recording + + redirect_to public_live_path(@session), alert: "Replay non disponibile" + end + + def authorize_replay_access! + return if Recordings::AccessPolicy.new(@recording, viewer: current_user).allowed? + + redirect_to public_live_index_path, alert: "Replay non disponibile" end end end diff --git a/backend/app/controllers/public/teams_controller.rb b/backend/app/controllers/public/teams_controller.rb index 7a2fdd0..9c651e0 100644 --- a/backend/app/controllers/public/teams_controller.rb +++ b/backend/app/controllers/public/teams_controller.rb @@ -116,40 +116,20 @@ module Public end def youtube_connect - require_youtube_connect_access!(@team) - @team.entitlements.assert_can_connect_youtube! - - if ENV["YOUTUBE_CLIENT_ID"].blank? - redirect_to public_team_details_path(@team), alert: "YouTube OAuth non configurato sul server" - return - end - - return_app = params[:return_app] == "1" - state = Youtube::OauthState.for_team( - team_id: @team.id, - user_id: current_user.id, - return_app: return_app + redirect_to public_club_youtube_connect_path( + @team.club_id, + return_app: params[:return_app] ) - redirect_to Youtube::OauthUrl.build(state: state), allow_other_host: true - rescue Teams::EntitlementError => e - redirect_to public_team_details_path(@team), alert: e.message end def youtube_disconnect require_club_owner_for_team!(@team) - @team.youtube_credential&.destroy! - redirect_to public_team_details_path(@team), notice: "Canale YouTube scollegato" + @team.club.youtube_credential&.destroy! + redirect_to public_club_path(@team.club), notice: "Canale YouTube della società scollegato" end private - def require_youtube_connect_access!(team) - return if team.club&.owned_by?(current_user) - return if current_user.can_stream_for?(team) - - redirect_to public_team_details_path(team), alert: "Accesso non consentito" - end - def load_team_details! @club = @team.club @can_manage = @club.owned_by?(current_user) @@ -160,7 +140,11 @@ module Public category: params[:category].presence_in(TeamRosterMember::CATEGORIES) || "player" ) end - @recordings = @team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(10) + @recordings = @team.recordings.not_deleted + .where(status: %w[ready processing]) + .includes(stream_session: :match) + .order(recorded_at: :desc, created_at: :desc) + .limit(10) @owner_membership = current_user.user_teams.find_by(team: @team) @staff_memberships = @team.user_teams.includes(:user) .where("user_teams.role = 'member' OR (user_teams.staff_kind IS NOT NULL)") diff --git a/backend/app/controllers/youtube_oauth_callback_controller.rb b/backend/app/controllers/youtube_oauth_callback_controller.rb index 83cc28d..ff4c050 100644 --- a/backend/app/controllers/youtube_oauth_callback_controller.rb +++ b/backend/app/controllers/youtube_oauth_callback_controller.rb @@ -13,12 +13,12 @@ class YoutubeOauthCallbackController < ActionController::Base end user = User.find(ctx[:user_id]) - team = Team.find(ctx[:team_id]) - unless user.can_stream_for?(team) || team.club&.owned_by?(user) - return redirect_to_oauth_error("Non autorizzato a collegare YouTube per questa squadra") + club = Club.find(ctx[:club_id]) + unless club.owned_by?(user) + return redirect_to_oauth_error("Solo il titolare della società può collegare il canale YouTube") end - cred = team.youtube_credential || team.build_youtube_credential + cred = club.youtube_credential || club.build_youtube_credential cred.update!( access_token: tokens[:access_token], refresh_token: tokens[:refresh_token] || cred.refresh_token, @@ -28,9 +28,9 @@ class YoutubeOauthCallbackController < ActionController::Base ) if ctx[:return_app] - redirect_to "matchlivetv://youtube-connected?team_id=#{team.id}", allow_other_host: true + redirect_to "matchlivetv://youtube-connected?club_id=#{club.id}", allow_other_host: true else - redirect_to "#{public_base_url}/teams/#{team.id}/dettagli?youtube=connected", allow_other_host: true + redirect_to "#{public_base_url}/clubs/#{club.id}?youtube=connected", allow_other_host: true end rescue ArgumentError, Youtube::BroadcastService::Error => e redirect_to_oauth_error(e.message) diff --git a/backend/app/jobs/recordings/expiry_warning_job.rb b/backend/app/jobs/recordings/expiry_warning_job.rb new file mode 100644 index 0000000..e12df9e --- /dev/null +++ b/backend/app/jobs/recordings/expiry_warning_job.rb @@ -0,0 +1,18 @@ +module Recordings + class ExpiryWarningJob + include Sidekiq::Job + sidekiq_options retry: 1, queue: "default" + + def perform + Recording.ready + .where(expiry_warning_sent_at: nil) + .where.not(expires_at: nil) + .where(expires_at: ..7.days.from_now) + .find_each do |recording| + Recordings::NotifyExpiring.new(recording).call + rescue StandardError => e + Rails.logger.warn("[Recordings::ExpiryWarningJob] #{recording.id}: #{e.message}") + end + end + end +end diff --git a/backend/app/jobs/recordings/post_process_job.rb b/backend/app/jobs/recordings/post_process_job.rb new file mode 100644 index 0000000..b09ec46 --- /dev/null +++ b/backend/app/jobs/recordings/post_process_job.rb @@ -0,0 +1,18 @@ +module Recordings + class PostProcessJob + include Sidekiq::Job + sidekiq_options retry: 2, queue: "default" + + def perform(recording_id) + recording = Recording.find_by(id: recording_id) + return unless recording&.ready? + + Recordings::NotifyReady.new(recording).call + + return unless recording.auto_publish_youtube? + return if recording.youtube_video_id.present? + + Recordings::PublishToYoutubeJob.perform_async(recording.id) + end + end +end diff --git a/backend/app/jobs/recordings/publish_to_youtube_job.rb b/backend/app/jobs/recordings/publish_to_youtube_job.rb new file mode 100644 index 0000000..e54a5dd --- /dev/null +++ b/backend/app/jobs/recordings/publish_to_youtube_job.rb @@ -0,0 +1,17 @@ +module Recordings + class PublishToYoutubeJob + include Sidekiq::Job + sidekiq_options retry: 2, queue: "default" + + def perform(recording_id) + recording = Recording.find_by(id: recording_id) + return unless recording&.ready? + + Recordings::PublishToYoutube.new(recording).call + rescue Recordings::PublishToYoutube::Error => e + meta = recording.metadata.merge("youtube_publish_error" => e.message, "youtube_publish_failed_at" => Time.current.iso8601) + recording.update!(metadata: meta) + Rails.logger.warn("[Recordings::PublishToYoutubeJob] #{recording_id}: #{e.message}") + end + end +end diff --git a/backend/app/jobs/recordings/purge_expired_job.rb b/backend/app/jobs/recordings/purge_expired_job.rb new file mode 100644 index 0000000..8c857a1 --- /dev/null +++ b/backend/app/jobs/recordings/purge_expired_job.rb @@ -0,0 +1,14 @@ +module Recordings + class PurgeExpiredJob + include Sidekiq::Job + sidekiq_options retry: 1, queue: "default" + + def perform + Recording.expired_pending_purge.find_each do |recording| + Recordings::Delete.new(recording, reason: :expired).call + rescue StandardError => e + Rails.logger.warn("[Recordings::PurgeExpiredJob] #{recording.id}: #{e.message}") + end + end + end +end diff --git a/backend/app/jobs/recordings/upload_job.rb b/backend/app/jobs/recordings/upload_job.rb new file mode 100644 index 0000000..05cc139 --- /dev/null +++ b/backend/app/jobs/recordings/upload_job.rb @@ -0,0 +1,14 @@ +module Recordings + class UploadJob + include Sidekiq::Job + sidekiq_options retry: 3, queue: "default" + + def perform(session_id) + session = StreamSession.find_by(id: session_id) + return unless session + + Recordings::UploadFromSession.new(session).call + Recordings::PurgeExpiredJob.perform_async + end + end +end diff --git a/backend/app/mailers/recordings/replay_mailer.rb b/backend/app/mailers/recordings/replay_mailer.rb new file mode 100644 index 0000000..87dfe96 --- /dev/null +++ b/backend/app/mailers/recordings/replay_mailer.rb @@ -0,0 +1,34 @@ +module Recordings + class ReplayMailer < ApplicationMailer + default from: -> { MatchLiveTv.mail_from } + + def replay_ready(recording:, recipient:) + @recording = recording + @team = recording.team + @club = @team.club + @recipient = recipient + @replay_url = recording.replay_url + @expires_at = recording.expires_at + + mail( + to: recipient.email, + subject: "Replay pronto — #{recording.title_or_default}" + ) + end + + def replay_expiring_soon(recording:, recipient:) + @recording = recording + @team = recording.team + @club = @team.club + @recipient = recipient + @replay_url = recording.replay_url + @expires_at = recording.expires_at + @days_left = ((recording.expires_at - Time.current) / 1.day).ceil + + mail( + to: recipient.email, + subject: "Replay in scadenza tra #{@days_left} giorni — #{recording.title_or_default}" + ) + end + end +end diff --git a/backend/app/models/club.rb b/backend/app/models/club.rb index e5c115d..ce87000 100644 --- a/backend/app/models/club.rb +++ b/backend/app/models/club.rb @@ -5,6 +5,7 @@ class Club < ApplicationRecord has_many :club_memberships, dependent: :destroy has_many :users, through: :club_memberships has_many :teams, dependent: :destroy + has_one :youtube_credential, dependent: :destroy has_one :subscription, dependent: :destroy has_many :billing_payments, class_name: "Billing::Payment", dependent: :destroy has_many :billing_invoices, class_name: "Billing::Invoice", dependent: :destroy diff --git a/backend/app/models/recording.rb b/backend/app/models/recording.rb index edd5000..51dec84 100644 --- a/backend/app/models/recording.rb +++ b/backend/app/models/recording.rb @@ -1,21 +1,160 @@ class Recording < ApplicationRecord - STATUSES = %w[processing ready expired].freeze + STATUSES = %w[processing ready expired failed].freeze + PRIVACY_STATUSES = %w[public unlisted].freeze + STORAGE_BACKENDS = %w[local s3].freeze belongs_to :stream_session belongs_to :team validates :status, inclusion: { in: STATUSES } + validates :privacy_status, inclusion: { in: PRIVACY_STATUSES } + validates :storage_backend, inclusion: { in: STORAGE_BACKENDS } - scope :ready, -> { where(status: "ready").where("expires_at IS NULL OR expires_at > ?", Time.current) } + scope :not_deleted, -> { where(deleted_at: nil) } + scope :ready, lambda { + not_deleted.where(status: "ready").where("expires_at IS NULL OR expires_at > ?", Time.current) + } + scope :publicly_listed, -> { where(privacy_status: "public") } scope :for_team, ->(team) { where(team: team) } + scope :for_club, lambda { |club| + joins(:team).where(teams: { club_id: club.id }) + } + scope :expiring_within, lambda { |days| + ready.where(expires_at: ..days.days.from_now) + } + scope :expired_pending_purge, lambda { + not_deleted.where(status: %w[ready failed]).where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) + } + scope :search_replays, lambda { |query| + q = query.to_s.strip + return all if q.blank? + + term = "%#{sanitize_sql_like(q)}%" + joins(stream_session: { match: { team: :club } }).where( + "recordings.title ILIKE :term OR teams.name ILIKE :term OR matches.opponent_name ILIKE :term OR clubs.name ILIKE :term OR matches.location ILIKE :term", + term: term + ) + } + + before_validation :normalize_privacy_status def replay_url - return nil unless ready? && stream_session_id.present? + return nil unless stream_session_id.present? + return nil unless ready? || status == "processing" "#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}" end + def playback_stream_url + return nil unless ready? && stream_session_id.present? + + "#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/stream" + end + + def thumbnail_url + return nil unless thumbnail_storage_key.present? && stream_session_id.present? + + "#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/thumbnail" + end + + def download_api_path + return nil unless ready? + + "/api/v1/recordings/#{id}/download" + end + + def youtube_watch_url + return nil if youtube_video_id.blank? + return nil if youtube_video_id.to_s.start_with?("mock_") + + "https://www.youtube.com/watch?v=#{youtube_video_id}" + end + def ready? - status == "ready" && (expires_at.nil? || expires_at.future?) + status == "ready" && !deleted? && (expires_at.nil? || expires_at.future?) + end + + def deleted? + deleted_at.present? + end + + def publicly_listed? + privacy_status == "public" + end + + def unlisted? + privacy_status == "unlisted" + end + + def auto_publish_youtube? + metadata.is_a?(Hash) && metadata["auto_publish_youtube"] == true + end + + def ai_metadata + metadata.fetch("ai", {}) + end + + def merge_metadata!(attrs) + update!(metadata: metadata.merge(attrs.stringify_keys)) + end + + def title_or_default + title.presence || default_title + end + + def default_title + match = stream_session&.match + return "Replay" unless match + + "#{match.team.name} vs #{match.opponent_name}" + end + + def recorded_at_or_fallback + recorded_at || stream_session&.ended_at || stream_session&.started_at || created_at + end + + def duration_label + return "—" if duration_secs.to_i <= 0 + + mins = duration_secs / 60 + secs = duration_secs % 60 + format("%d:%02d", mins, secs) + end + + def byte_size_label + bytes = byte_size.to_i + return "—" if bytes <= 0 + + if bytes >= 1.gigabyte + format("%.1f GB", bytes / 1.gigabyte.to_f) + elsif bytes >= 1.megabyte + format("%.1f MB", bytes / 1.megabyte.to_f) + else + format("%.0f KB", bytes / 1.kilobyte.to_f) + end + end + + def views_label + count = view_count.to_i + return "0 visualizzazioni" if count.zero? + + count == 1 ? "1 visualizzazione" : "#{count} visualizzazioni" + end + + def status_label + return "Eliminato" if deleted? + return "Scaduto" if status == "expired" + return "Errore" if status == "failed" + return "In elaborazione" if status == "processing" + return "Scade presto" if expires_at.present? && expires_at <= 7.days.from_now + + "Disponibile" + end + + private + + def normalize_privacy_status + self.privacy_status = "public" if privacy_status == "private" + self.privacy_status = "unlisted" if privacy_status.blank? end end diff --git a/backend/app/models/team.rb b/backend/app/models/team.rb index 814263f..72a893d 100644 --- a/backend/app/models/team.rb +++ b/backend/app/models/team.rb @@ -5,7 +5,6 @@ class Team < ApplicationRecord has_many :user_teams, dependent: :destroy has_many :users, through: :user_teams has_many :matches, dependent: :destroy - has_one :youtube_credential, dependent: :destroy has_many :recordings, dependent: :destroy has_many :team_invitations, dependent: :destroy has_many :roster_members, class_name: "TeamRosterMember", dependent: :destroy @@ -23,6 +22,11 @@ class Team < ApplicationRecord Teams::Entitlements.new(self) end + # Canale YouTube OAuth è unico per tutta la società (Premium Full). + def youtube_credential + club&.youtube_credential + end + def owner club&.owner end diff --git a/backend/app/models/youtube_credential.rb b/backend/app/models/youtube_credential.rb index db04251..808a54b 100644 --- a/backend/app/models/youtube_credential.rb +++ b/backend/app/models/youtube_credential.rb @@ -1,5 +1,5 @@ class YoutubeCredential < ApplicationRecord - belongs_to :team + belongs_to :club attr_encrypted :access_token, key: :encryption_key, diff --git a/backend/app/services/admin/dashboard_stats.rb b/backend/app/services/admin/dashboard_stats.rb index db0d9e4..1e623d6 100644 --- a/backend/app/services/admin/dashboard_stats.rb +++ b/backend/app/services/admin/dashboard_stats.rb @@ -19,8 +19,10 @@ module Admin teams_count: Team.count, users_count: User.count, matches_count: Match.count, - recordings_count: Recording.count, - recordings_ready: Recording.where(status: "ready").count + recordings_count: Recording.not_deleted.count, + recordings_ready: Recording.ready.count, + recordings_bytes: Recording.ready.sum(:byte_size).to_i, + recordings_expiring_soon: Recording.ready.expiring_within(7).count, } end end diff --git a/backend/app/services/mediamtx/client.rb b/backend/app/services/mediamtx/client.rb index 4ad7451..9fedb8c 100644 --- a/backend/app/services/mediamtx/client.rb +++ b/backend/app/services/mediamtx/client.rb @@ -25,6 +25,10 @@ module Mediamtx recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S", recordSegmentDuration: "60s" } + if record + retention_hours = session.match.team.entitlements.recording_retention_days * 24 + 48 + body[:recordDeleteAfter] = "#{retention_hours}h" + end unless session.matchlivetv_platform? body[:runOnReady] = run_on_ready_script(session) body[:runOnReadyRestart] = true diff --git a/backend/app/services/recordings/access_policy.rb b/backend/app/services/recordings/access_policy.rb new file mode 100644 index 0000000..ba82658 --- /dev/null +++ b/backend/app/services/recordings/access_policy.rb @@ -0,0 +1,33 @@ +module Recordings + class AccessPolicy + def initialize(recording, viewer: nil) + @recording = recording + @viewer = viewer + end + + def allowed? + return false if @recording.deleted? + return true if @recording.status == "processing" && (club_member? || manage_allowed?) + return false unless @recording.ready? + + return true if @recording.unlisted? || @recording.publicly_listed? + + club_member? + end + + def manage_allowed? + return false unless @viewer + + team = @recording.team + team.club.owned_by?(@viewer) || @viewer.manageable_teams.exists?(id: team.id) + end + + private + + def club_member? + return false unless @viewer + + @recording.team.club.users.exists?(id: @viewer.id) + end + end +end diff --git a/backend/app/services/recordings/club_stats.rb b/backend/app/services/recordings/club_stats.rb new file mode 100644 index 0000000..fd226cb --- /dev/null +++ b/backend/app/services/recordings/club_stats.rb @@ -0,0 +1,20 @@ +module Recordings + class ClubStats + def initialize(club) + @club = club + end + + def call + scope = Recording.for_club(@club).not_deleted + ready = scope.ready + { + available_count: ready.count, + expiring_soon_count: ready.expiring_within(7).count, + total_bytes: ready.sum(:byte_size).to_i, + total_views: ready.sum(:view_count).to_i, + processing_count: scope.where(status: "processing").count, + failed_count: scope.where(status: "failed").count + } + end + end +end diff --git a/backend/app/services/recordings/delete.rb b/backend/app/services/recordings/delete.rb new file mode 100644 index 0000000..d01c6a9 --- /dev/null +++ b/backend/app/services/recordings/delete.rb @@ -0,0 +1,38 @@ +module Recordings + class Delete + def initialize(recording, reason: :manual) + @recording = recording + @reason = reason + end + + def call + delete_storage_object if @recording.storage_key.present? + cleanup_local_artifacts + + @recording.update!( + status: "expired", + deleted_at: Time.current, + storage_key: nil, + thumbnail_storage_key: nil + ) + @recording + end + + private + + def delete_storage_object + storage = Recordings::Storage.new + storage.delete(key: @recording.storage_key) if @recording.storage_key.present? + storage.delete(key: @recording.thumbnail_storage_key) if @recording.thumbnail_storage_key.present? + rescue Recordings::Storage::Error => e + Rails.logger.warn("[Recordings::Delete] storage delete failed: #{e.message}") + end + + def cleanup_local_artifacts + return if @recording.storage_path.blank? + + base = File.join(MatchLiveTv.recordings_local_path, @recording.storage_path) + FileUtils.rm_rf(base) if Dir.exist?(base) + end + end +end diff --git a/backend/app/services/recordings/download_url.rb b/backend/app/services/recordings/download_url.rb new file mode 100644 index 0000000..22b3160 --- /dev/null +++ b/backend/app/services/recordings/download_url.rb @@ -0,0 +1,28 @@ +module Recordings + class DownloadUrl + class NotAllowed < StandardError; end + + def initialize(recording, viewer: nil) + @recording = recording + @viewer = viewer + end + + def call + raise NotAllowed, "Download non disponibile per questo piano" unless download_allowed? + raise ArgumentError, "Replay non pronto" unless @recording.ready? + raise ArgumentError, "File non disponibile" if @recording.storage_key.blank? + + "#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{@recording.stream_session_id}/download" + end + + private + + def download_allowed? + ent = @recording.team.entitlements + return false unless ent.phone_download_enabled? + + policy = Recordings::AccessPolicy.new(@recording, viewer: @viewer) + policy.allowed? || policy.manage_allowed? + end + end +end diff --git a/backend/app/services/recordings/finalize_session.rb b/backend/app/services/recordings/finalize_session.rb new file mode 100644 index 0000000..826435d --- /dev/null +++ b/backend/app/services/recordings/finalize_session.rb @@ -0,0 +1,51 @@ +module Recordings + class FinalizeSession + def initialize(session) + @session = session + end + + def call + team = @session.match.team + return unless team.entitlements.can_access_recordings? + + retention_days = team.entitlements.recording_retention_days + expires_at = retention_days.positive? ? retention_days.days.from_now : nil + + recording = Recording.find_or_initialize_by(stream_session: @session) + recording.assign_attributes( + team: team, + status: "processing", + title: default_title, + privacy_status: privacy_from_session, + storage_path: @session.mediamtx_path_name, + recorded_at: @session.ended_at || Time.current, + expires_at: expires_at, + error_message: nil, + metadata: initial_metadata(team) + ) + recording.save! + recording + end + + private + + def default_title + match = @session.match + "#{match.team.name} vs #{match.opponent_name}" + end + + def privacy_from_session + @session.privacy_status == "public" ? "public" : "unlisted" + end + + def initial_metadata(team) + ent = team.entitlements + { + "source_platform" => @session.platform, + "session_privacy" => @session.privacy_status, + "auto_publish_youtube" => ent.premium_full? && ent.youtube_enabled? && @session.platform == "youtube", + "ai" => {} + } + end + end +end diff --git a/backend/app/services/recordings/generate_thumbnail.rb b/backend/app/services/recordings/generate_thumbnail.rb new file mode 100644 index 0000000..e3681f4 --- /dev/null +++ b/backend/app/services/recordings/generate_thumbnail.rb @@ -0,0 +1,55 @@ +module Recordings + class GenerateThumbnail + class Error < StandardError; end + + def initialize(recording, video_path: nil) + @recording = recording + @video_path = video_path + end + + def call + return @recording if @recording.thumbnail_storage_key.present? + + path = @video_path || local_video_path + return @recording unless path && File.exist?(path) + + thumb_path = extract_frame(path) + key = thumbnail_key + Recordings::Storage.new.upload(local_path: thumb_path, key: key, content_type: "image/jpeg") + @recording.update!(thumbnail_storage_key: key) + FileUtils.rm_f(thumb_path) + @recording + rescue Recordings::Storage::Error => e + Rails.logger.warn("[Recordings::GenerateThumbnail] #{@recording.id}: #{e.message}") + @recording + ensure + FileUtils.rm_f(@thumb_temp) if @thumb_temp && File.exist?(@thumb_temp) + end + + private + + def local_video_path + return nil if @recording.storage_key.blank? + + Recordings::Storage.new.local_path_for_key(@recording.storage_key) + end + + def extract_frame(video_path) + @thumb_temp = File.join(Dir.tmpdir, "thumb-#{@recording.id}-#{SecureRandom.hex(4)}.jpg") + dur = @recording.duration_secs.to_i + offset = dur.positive? ? [[dur / 4, 5].max, dur - 1].min : 1 + success = system( + "ffmpeg", "-y", "-ss", offset.to_s, "-i", video_path, + "-vframes", "1", "-q:v", "2", @thumb_temp, + out: File::NULL, err: File::NULL + ) + raise Error, "ffmpeg thumbnail failed" unless success && File.exist?(@thumb_temp) + + @thumb_temp + end + + def thumbnail_key + "teams/#{@recording.team_id}/sessions/#{@recording.stream_session_id}/thumbnail.jpg" + end + end +end diff --git a/backend/app/services/recordings/increment_views.rb b/backend/app/services/recordings/increment_views.rb new file mode 100644 index 0000000..cb05552 --- /dev/null +++ b/backend/app/services/recordings/increment_views.rb @@ -0,0 +1,14 @@ +module Recordings + class IncrementViews + def initialize(recording) + @recording = recording + end + + def call + return unless @recording.ready? + + Recording.where(id: @recording.id).update_all("view_count = view_count + 1") + @recording.reload + end + end +end diff --git a/backend/app/services/recordings/index_session.rb b/backend/app/services/recordings/index_session.rb index fe5c227..0b2878b 100644 --- a/backend/app/services/recordings/index_session.rb +++ b/backend/app/services/recordings/index_session.rb @@ -1,25 +1,12 @@ module Recordings + # @deprecated Use Recordings::FinalizeSession class IndexSession def initialize(session) @session = session end def call - team = @session.match.team - return unless team.entitlements.can_access_recordings? - - retention_days = team.entitlements.recording_retention_days - expires_at = retention_days.positive? ? retention_days.days.from_now : nil - - recording = Recording.find_or_initialize_by(stream_session: @session) - recording.assign_attributes( - team: team, - status: "ready", - storage_path: @session.mediamtx_path_name, - expires_at: expires_at - ) - recording.save! - recording + FinalizeSession.new(@session).call end end end diff --git a/backend/app/services/recordings/notify_expiring.rb b/backend/app/services/recordings/notify_expiring.rb new file mode 100644 index 0000000..f6f18aa --- /dev/null +++ b/backend/app/services/recordings/notify_expiring.rb @@ -0,0 +1,33 @@ +module Recordings + class NotifyExpiring + WINDOW_DAYS = 7 + + def initialize(recording) + @recording = recording + end + + def call + return @recording if @recording.expiry_warning_sent_at.present? + return @recording unless @recording.ready? + return @recording if @recording.expires_at.blank? + return @recording unless @recording.expires_at <= WINDOW_DAYS.days.from_now + + recipients.each do |user| + Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user).deliver_now + end + + @recording.update!(expiry_warning_sent_at: Time.current) + @recording + end + + private + + def recipients + club = @recording.team.club + owner = club.owner + users = club.users.distinct.to_a + users << owner if owner && users.none? { |u| u.id == owner.id } + users.uniq.select { |u| u.email.present? } + end + end +end diff --git a/backend/app/services/recordings/notify_ready.rb b/backend/app/services/recordings/notify_ready.rb new file mode 100644 index 0000000..50718b8 --- /dev/null +++ b/backend/app/services/recordings/notify_ready.rb @@ -0,0 +1,29 @@ +module Recordings + class NotifyReady + def initialize(recording) + @recording = recording + end + + def call + return @recording if @recording.ready_notified_at.present? + return @recording unless @recording.ready? + + recipients.each do |user| + Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user).deliver_now + end + + @recording.update!(ready_notified_at: Time.current) + @recording + end + + private + + def recipients + club = @recording.team.club + owner = club.owner + users = club.users.distinct.to_a + users << owner if owner && users.none? { |u| u.id == owner.id } + users.uniq.select { |u| u.email.present? } + end + end +end diff --git a/backend/app/services/recordings/playback_url.rb b/backend/app/services/recordings/playback_url.rb new file mode 100644 index 0000000..69fcb64 --- /dev/null +++ b/backend/app/services/recordings/playback_url.rb @@ -0,0 +1,20 @@ +module Recordings + class PlaybackUrl + def initialize(recording) + @recording = recording + end + + def call + raise ArgumentError, "Replay non pronto" unless @recording.ready? + raise ArgumentError, "Storage key mancante" if @recording.storage_key.blank? + + "#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{@recording.stream_session_id}/stream" + end + + def local_path + storage = Recordings::Storage.new + path = storage.local_path_for_key(@recording.storage_key) + path if path && File.exist?(path) + end + end +end diff --git a/backend/app/services/recordings/publish_to_youtube.rb b/backend/app/services/recordings/publish_to_youtube.rb new file mode 100644 index 0000000..d0d8b0e --- /dev/null +++ b/backend/app/services/recordings/publish_to_youtube.rb @@ -0,0 +1,79 @@ +module Recordings + class PublishToYoutube + class Error < StandardError; end + + def initialize(recording, privacy_status: nil) + @recording = recording + @privacy_status = privacy_status + end + + def call + team = @recording.team + ent = team.entitlements + raise Error, "Republicazione YouTube disponibile con Premium Full" unless ent.premium_full? && ent.youtube_enabled? + raise Error, "Replay già pubblicato su YouTube" if @recording.youtube_video_id.present? + raise Error, "Replay non pronto" unless @recording.ready? + + video_path = local_video_path + raise Error, "File video non disponibile in locale; riprova dopo l’upload" unless video_path + + session = @recording.stream_session + privacy = @privacy_status.presence || youtube_privacy + channel = session.platform == "youtube" ? "team" : nil + + result = Youtube::VideoUploadService.new(team, youtube_channel: channel).upload_file!( + file_path: video_path, + title: @recording.title_or_default, + description: youtube_description(session), + privacy_status: privacy, + tags: %w[MatchLiveTV replay sport] + ) + + meta = @recording.metadata.merge( + "youtube_publish" => { + "video_id" => result[:video_id], + "watch_url" => result[:watch_url], + "published_at" => Time.current.iso8601, + "privacy_status" => privacy + } + ) + + @recording.update!( + youtube_video_id: result[:video_id], + youtube_published_at: Time.current, + metadata: meta + ) + @recording + ensure + FileUtils.rm_f(@temp_video) if @temp_video && File.exist?(@temp_video) + end + + private + + def local_video_path + return @temp_video if @temp_video && File.exist?(@temp_video) + + if @recording.storage_key.blank? + return nil + end + + storage = Recordings::Storage.new + path = storage.local_path_for_key(@recording.storage_key) + return path if path && File.exist?(path) + + @temp_video = storage.download_to_temp(key: @recording.storage_key) + @temp_video + end + + def youtube_privacy + @recording.publicly_listed? ? "public" : "unlisted" + end + + def youtube_description(session) + match = session.match + base = "#{match.team.name} vs #{match.opponent_name}" + url = @recording.replay_url + [base, url, "Trasmesso con Match Live TV"].compact.join("\n\n") + end + end +end diff --git a/backend/app/services/recordings/serve_from_storage.rb b/backend/app/services/recordings/serve_from_storage.rb new file mode 100644 index 0000000..3fd179c --- /dev/null +++ b/backend/app/services/recordings/serve_from_storage.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "aws-sdk-s3" + +module Recordings + # Serve file replay/thumbnail al browser senza esporre URL S3 interni (es. http://garage:3900). + class ServeFromStorage + def initialize(key:, content_type:, disposition: "inline", filename: nil) + @key = key + @content_type = content_type + @disposition = disposition + @filename = filename + end + + def call(controller) + path = local_path + return controller.send_file(path, type: @content_type, disposition: @disposition, filename: @filename) if path + + proxy_s3(controller) + end + + def local_path + path = Recordings::Storage.new.local_path_for_key(@key) + path if path && File.exist?(path) + end + + private + + def proxy_s3(controller) + raise ArgumentError, "Storage key mancante" if @key.blank? + raise ArgumentError, "Storage S3 non configurato" if MatchLiveTv.replay_storage_local? + + range = controller.request.headers["Range"] + resp = s3_client.get_object( + bucket: MatchLiveTv.replay_storage_bucket, + key: @key, + range: range.presence + ) + + controller.response.headers["Content-Type"] = @content_type + 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? + + 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? + end + + def s3_client + @s3_client ||= Aws::S3::Client.new( + access_key_id: MatchLiveTv.replay_storage_access_key_id, + secret_access_key: MatchLiveTv.replay_storage_secret_access_key, + 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 + end +end diff --git a/backend/app/services/recordings/storage.rb b/backend/app/services/recordings/storage.rb new file mode 100644 index 0000000..49c6ecf --- /dev/null +++ b/backend/app/services/recordings/storage.rb @@ -0,0 +1,123 @@ +require "open-uri" +require "aws-sdk-s3" + +module Recordings + class Storage + class Error < StandardError; end + + def initialize + @backend = MatchLiveTv.replay_storage_local? ? LocalBackend.new : S3Backend.new + end + + def upload(local_path:, key:, content_type: "video/mp4") + @backend.upload(local_path: local_path, key: key, content_type: content_type) + end + + def delete(key:) + @backend.delete(key: key) + end + + def download_to_temp(key:) + local = local_path_for_key(key) + return local if local && File.exist?(local) + + url = presigned_get_url(key: key, expires_in: 600) + raise Error, "Impossibile scaricare file dallo storage" if url.blank? + + dest = File.join(Dir.tmpdir, "replay-dl-#{SecureRandom.hex(8)}.mp4") + URI.open(url) do |remote| + File.binwrite(dest, remote.read) + end + dest + end + + def presigned_get_url(key:, expires_in: MatchLiveTv.replay_presigned_url_ttl_seconds) + @backend.presigned_get_url(key: key, expires_in: expires_in) + end + + def local_path_for_key(key) + @backend.local_path_for_key(key) + end + + def backend_name + @backend.name + end + + class LocalBackend + def name + "local" + end + + def upload(local_path:, key:, content_type:) + dest = local_path_for_key(key) + FileUtils.mkdir_p(File.dirname(dest)) + FileUtils.cp(local_path, dest) + File.size(dest) + end + + def delete(key:) + path = local_path_for_key(key) + FileUtils.rm_f(path) + true + end + + def presigned_get_url(key:, expires_in:) + nil + end + + def local_path_for_key(key) + File.join(MatchLiveTv.recordings_local_path, "replays", key) + end + end + + class S3Backend + def name + "s3" + end + + def upload(local_path:, key:, content_type:) + client.put_object( + bucket: MatchLiveTv.replay_storage_bucket, + key: key, + body: File.open(local_path, "rb"), + content_type: content_type + ) + File.size(local_path) + end + + def delete(key:) + client.delete_object(bucket: MatchLiveTv.replay_storage_bucket, key: key) + true + rescue Aws::S3::Errors::ServiceError => e + raise Error, e.message + end + + def presigned_get_url(key:, expires_in:) + signer = Aws::S3::Presigner.new(client: client) + signer.presigned_url( + :get_object, + bucket: MatchLiveTv.replay_storage_bucket, + key: key, + expires_in: expires_in + ) + end + + def local_path_for_key(_key) + nil + end + + private + + def client + @client ||= Aws::S3::Client.new( + access_key_id: MatchLiveTv.replay_storage_access_key_id, + secret_access_key: MatchLiveTv.replay_storage_secret_access_key, + endpoint: MatchLiveTv.replay_storage_endpoint, + region: MatchLiveTv.replay_storage_region, + force_path_style: MatchLiveTv.replay_storage_force_path_style?, + response_checksum_validation: "WHEN_REQUIRED" + ) + end + end + end +end diff --git a/backend/app/services/recordings/upload_from_session.rb b/backend/app/services/recordings/upload_from_session.rb new file mode 100644 index 0000000..7218fc8 --- /dev/null +++ b/backend/app/services/recordings/upload_from_session.rb @@ -0,0 +1,104 @@ +module Recordings + class UploadFromSession + class Error < StandardError; end + + def initialize(session) + @session = session + end + + def call + recording = Recording.find_by(stream_session: @session) + return unless recording&.status == "processing" + + source_files = local_source_files + if source_files.empty? + fail_recording!(recording, "Nessun file di registrazione trovato") + cleanup_mediamtx_path + return recording + end + + merged_path = merge_source_files(source_files) + duration_secs = probe_duration(merged_path) + storage_key = object_key(recording) + storage = Recordings::Storage.new + byte_size = storage.upload(local_path: merged_path, key: storage_key) + + Recordings::GenerateThumbnail.new(recording, video_path: merged_path).call + + recording.update!( + status: "ready", + storage_key: storage_key, + storage_bucket: MatchLiveTv.replay_storage_bucket, + storage_backend: storage.backend_name, + byte_size: byte_size, + duration_secs: duration_secs, + error_message: nil + ) + + cleanup_local_sources(source_files, merged_path) + cleanup_mediamtx_path + Recordings::PostProcessJob.perform_async(recording.id) + recording + rescue Error, Recordings::Storage::Error => e + recording = Recording.find_by(stream_session: @session) + fail_recording!(recording, e.message) if recording + raise + ensure + FileUtils.rm_f(@merged_temp_path) if @merged_temp_path && File.exist?(@merged_temp_path) + end + + private + + def local_source_files + base = File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name) + return [] unless Dir.exist?(base) + + Dir.glob(File.join(base, "**", "*")) + .select { |path| File.file?(path) && path.match?(/\.(mp4|fmp4|m4s|ts)$/i) } + .sort_by { |path| File.basename(path) } + end + + def merge_source_files(source_files) + return source_files.first if source_files.one? + + @merged_temp_path = File.join(Dir.tmpdir, "replay-#{@session.id}-#{SecureRandom.hex(4)}.mp4") + list_path = "#{@merged_temp_path}.txt" + File.write(list_path, source_files.map { |f| "file '#{f.gsub("'", "'\\''")}'" }.join("\n")) + success = system("ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", @merged_temp_path, + out: File::NULL, err: File::NULL) + FileUtils.rm_f(list_path) + raise Error, "Impossibile unire i segmenti video" unless success && File.exist?(@merged_temp_path) + + @merged_temp_path + end + + def probe_duration(path) + output = `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "#{path}" 2>/dev/null` + output.to_f.round + rescue StandardError + @session.total_duration_secs.to_i + end + + def object_key(recording) + "teams/#{recording.team_id}/sessions/#{@session.id}/replay.mp4" + end + + def cleanup_local_sources(source_files, merged_path) + source_files.each { |path| FileUtils.rm_f(path) } + base = File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name) + FileUtils.rm_rf(base) if Dir.exist?(base) + FileUtils.rm_f(merged_path) if merged_path != source_files.first + end + + def cleanup_mediamtx_path + Mediamtx::Client.new.delete_path(@session) + rescue Mediamtx::Client::Error => e + Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}") + end + + def fail_recording!(recording, message) + recording.update!(status: "failed", error_message: message) + cleanup_mediamtx_path + end + end +end diff --git a/backend/app/services/sessions/stop.rb b/backend/app/services/sessions/stop.rb index 78ca9cd..2adfd75 100644 --- a/backend/app/services/sessions/stop.rb +++ b/backend/app/services/sessions/stop.rb @@ -9,8 +9,14 @@ module Sessions Sessions::RegiaAccess.revoke!(@session) @session.end_stream! complete_youtube_broadcast! if @session.youtube_broadcast_id.present? - Recordings::IndexSession.new(@session).call - Mediamtx::Client.new.delete_path(@session) + + recording = Recordings::FinalizeSession.new(@session).call + if recording + Recordings::UploadJob.perform_async(@session.id) + else + Mediamtx::Client.new.delete_path(@session) + end + log_event("ended") SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" }) @session diff --git a/backend/app/services/youtube/credential_resolver.rb b/backend/app/services/youtube/credential_resolver.rb index 7cf2317..c01aead 100644 --- a/backend/app/services/youtube/credential_resolver.rb +++ b/backend/app/services/youtube/credential_resolver.rb @@ -19,7 +19,7 @@ module Youtube when :platform PlatformCredential.configured? ? PlatformCredential.new : nil when :team - @team.youtube_credential + @team.club.youtube_credential end end @@ -32,7 +32,7 @@ module Youtube end def team_channel_available? - @ent.youtube_enabled? && @ent.premium_full? && @mode == "team" && @team.youtube_credential.present? + @ent.youtube_enabled? && @ent.premium_full? && @mode == "team" && @team.club.youtube_credential.present? end def effective_channel diff --git a/backend/app/services/youtube/oauth_state.rb b/backend/app/services/youtube/oauth_state.rb index e392d51..ca42d4e 100644 --- a/backend/app/services/youtube/oauth_state.rb +++ b/backend/app/services/youtube/oauth_state.rb @@ -3,12 +3,18 @@ module Youtube PURPOSE = :youtube_oauth_connect class << self - def for_team(team_id:, user_id:, return_app: false) - payload = [team_id.to_s, user_id.to_s] + def for_club(club_id:, user_id:, return_app: false) + payload = ["club", club_id.to_s, user_id.to_s] payload << "app" if return_app verifier.generate(payload, expires_in: 1.hour) end + # API mobile: accetta team_id ma firma OAuth per la società. + def for_team(team_id:, user_id:, return_app: false) + club_id = Team.find(team_id).club_id + for_club(club_id: club_id, user_id: user_id, return_app: return_app) + end + def for_platform(admin_id:) verifier.generate(["platform", admin_id.to_s], expires_in: 1.hour) end @@ -19,10 +25,21 @@ module Youtube when Array if payload.first == "platform" { kind: :platform, admin_id: payload[1] } - elsif payload.size >= 2 + elsif payload.first == "club" && payload.size >= 3 { - kind: :team, - team_id: payload[0], + kind: :club, + club_id: payload[1], + user_id: payload[2], + return_app: payload[3] == "app" + } + elsif payload.size >= 2 + # Stato legacy (team_id): risolvi la società + team = Team.find_by(id: payload[0]) + raise ArgumentError, "stato OAuth non valido" unless team + + { + kind: :club, + club_id: team.club_id, user_id: payload[1], return_app: payload[2] == "app" } diff --git a/backend/app/services/youtube/team_status.rb b/backend/app/services/youtube/team_status.rb index ff948d0..15ebb12 100644 --- a/backend/app/services/youtube/team_status.rb +++ b/backend/app/services/youtube/team_status.rb @@ -15,7 +15,7 @@ module Youtube when "matchlivetv_light" PlatformCredential.configured? when "team" - @team.youtube_credential.present? || PlatformCredential.configured? + @team.club.youtube_credential.present? || PlatformCredential.configured? else false end @@ -31,7 +31,7 @@ module Youtube if @mode == "matchlivetv_light" PlatformCredential.configured? elsif @mode == "team" - @team.youtube_credential.blank? && PlatformCredential.configured? + @team.club.youtube_credential.blank? && PlatformCredential.configured? else false end @@ -41,12 +41,12 @@ module Youtube if uses_platform_channel? PLATFORM_LABEL else - @team.youtube_credential&.channel_title + @team.club.youtube_credential&.channel_title end end def needs_team_oauth? - @mode == "team" && @ent.premium_full? && @team.youtube_credential.blank? && !PlatformCredential.configured? + @mode == "team" && @ent.premium_full? && @team.club.youtube_credential.blank? && !PlatformCredential.configured? end end end diff --git a/backend/app/services/youtube/video_upload_service.rb b/backend/app/services/youtube/video_upload_service.rb new file mode 100644 index 0000000..bcea881 --- /dev/null +++ b/backend/app/services/youtube/video_upload_service.rb @@ -0,0 +1,57 @@ +module Youtube + class VideoUploadService + class Error < StandardError; end + + def initialize(team, youtube_channel: nil) + @team = team + @resolver = CredentialResolver.new(team, channel: youtube_channel) + @credential = @resolver.resolve + end + + def upload_file!(file_path:, title:, description: nil, privacy_status: "unlisted", tags: []) + return mock_upload if @credential.blank? || missing_oauth_config? + raise Error, "File non trovato" unless File.exist?(file_path) + + client = authorized_client + video = Google::Apis::YoutubeV3::Video.new( + snippet: Google::Apis::YoutubeV3::VideoSnippet.new( + title: title, + description: description, + tags: tags.presence + ), + status: Google::Apis::YoutubeV3::VideoStatus.new( + privacy_status: privacy_status, + self_declared_made_for_kids: false + ) + ) + + result = client.insert_video( + "snippet,status", + video, + upload_source: file_path, + content_type: "video/mp4" + ) + { + video_id: result.id, + watch_url: "https://www.youtube.com/watch?v=#{result.id}" + } + rescue Google::Apis::Error => e + raise Error, e.message + end + + private + + def mock_upload + id = "mock_vod_#{SecureRandom.hex(6)}" + { video_id: id, watch_url: "https://www.youtube.com/watch?v=#{id}" } + end + + def missing_oauth_config? + ENV["YOUTUBE_CLIENT_ID"].blank? + end + + def authorized_client + OauthRefresh.new(@credential).apply!(Google::Apis::YoutubeV3::YouTubeService.new) + end + end +end diff --git a/backend/app/views/admin/clubs/show.html.erb b/backend/app/views/admin/clubs/show.html.erb index ee2c0fc..5a35b0c 100644 --- a/backend/app/views/admin/clubs/show.html.erb +++ b/backend/app/views/admin/clubs/show.html.erb @@ -9,6 +9,18 @@ <%= render "admin/clubs/comped_form", club: @club, subscription: @subscription, return_to: admin_club_path(@club) %> +<% cred = @club.youtube_credential %> +

+ YouTube società: + <% if cred %> + <%= cred.channel_title.presence || cred.channel_id %> (collegato) + <% elsif @teams.any? { |t| t.entitlements.premium_full? } %> + Premium Full — canale società non collegato (usa Match Live TV) + <% else %> + + <% end %> +

+

Squadre

<% if @teams.empty? %>

Nessuna squadra registrata.

@@ -18,32 +30,14 @@ Squadra Sport - YouTube <% @teams.each do |team| %> - <% yt = Youtube::TeamStatus.new(team) %> <%= team.name %> <%= team.sport %> - - <% if yt.selectable? %> - <% if yt.uses_platform_channel? %> - Match Live TV - <% if team.youtube_credential.blank? && team.entitlements.premium_full? %> - (default Full) - <% end %> - <% else %> - ✓ <%= yt.channel_title.presence || "Canale società" %> - <% end %> - <% elsif team.entitlements.youtube_enabled? %> - In configurazione - <% else %> - — - <% end %> - <%= link_to "Partite e dettagli", admin_team_path(team) %> <% end %> diff --git a/backend/app/views/admin/teams/show.html.erb b/backend/app/views/admin/teams/show.html.erb index 86810a5..ed39b9e 100644 --- a/backend/app/views/admin/teams/show.html.erb +++ b/backend/app/views/admin/teams/show.html.erb @@ -13,7 +13,7 @@ <% if yt.selectable? %> <%= yt.channel_title || "—" %> <% if yt.uses_platform_channel? %> - (canale piattaforma<%= @team.entitlements.premium_full? && @team.youtube_credential.blank? ? ", default senza OAuth società" : "" %>) + (canale piattaforma<%= @team.entitlements.premium_full? && @team.club.youtube_credential.blank? ? ", default senza OAuth società" : "" %>) <% else %> (canale società) <% end %> @@ -21,10 +21,10 @@ non pronto <% end %>

-<% if @team.entitlements.premium_full? && @team.youtube_credential.blank? %> +<% if @team.entitlements.premium_full? && @team.club.youtube_credential.blank? %>

Senza canale collegato, l’app usa il canale Match Live TV. - <%= link_to "Collega canale (sito pubblico)", public_team_details_path(@team) %>. + <%= link_to "Collega canale (pagina società)", public_club_path(@team.club) %>.

<% end %> diff --git a/backend/app/views/layouts/marketing_live.html.erb b/backend/app/views/layouts/marketing_live.html.erb index fd7b007..e17ce62 100644 --- a/backend/app/views/layouts/marketing_live.html.erb +++ b/backend/app/views/layouts/marketing_live.html.erb @@ -7,7 +7,7 @@ <%= render "shared/meta_tags" %> - + <%= yield :head %> diff --git a/backend/app/views/public/club_recordings/index.html.erb b/backend/app/views/public/club_recordings/index.html.erb new file mode 100644 index 0000000..934d53d --- /dev/null +++ b/backend/app/views/public/club_recordings/index.html.erb @@ -0,0 +1,91 @@ +<% content_for :title, "Replay — #{@club.name}" %> +<% content_for :robots, "noindex, nofollow" %> + +
+ <%= link_to "← #{@club.name}", public_club_path(@club), class: "back-link" %> + +

Archivio Replay

+

Gestisci registrazioni, visibilità, download e pubblicazione YouTube.

+ +
+
+
<%= @stats[:available_count] %>
+
Replay disponibili
+
+
+
<%= @stats[:expiring_soon_count] %>
+
In scadenza (7 gg)
+
+
+
<%= number_to_human_size(@stats[:total_bytes]) %>
+
Spazio archivio
+
+
+
<%= @stats[:total_views] %>
+
Visualizzazioni totali
+
+
+ + <% if @recordings.any? %> +
+ + + + + + + + + + + + + + + + <% @recordings.each do |rec| %> + + + + + + + + + + + + <% end %> + +
TitoloDataDurataViewsScadenzaStatoVisibilità
+ <% if rec.thumbnail_url %> + + <% end %> + <%= rec.title_or_default %><%= l_local(rec.recorded_at_or_fallback) %><%= rec.duration_label %><%= rec.view_count %><%= rec.expires_at ? l_local(rec.expires_at) : "—" %><%= rec.status_label %> + <%= form_with url: public_club_recording_path(@club, rec), method: :patch, local: true do %> + <%= select_tag "recording[privacy_status]", + options_for_select([["Pubblico", "public"], ["Privato", "unlisted"]], rec.privacy_status), + onchange: "this.form.submit()" %> + <% end %> + + <% if rec.replay_url %> + <%= link_to "Guarda", 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) %> + <% end %> + <% ent = rec.team.entitlements %> + <% if ent.premium_full? && ent.youtube_enabled? && rec.ready? && rec.youtube_video_id.blank? %> + · <%= button_to "YouTube", public_club_recording_publish_youtube_path(@club, rec), method: :post, class: "btn-link", style: "display:inline;padding:0;border:0;background:none;color:inherit;cursor:pointer;text-decoration:underline" %> + <% elsif rec.youtube_watch_url %> + · <%= link_to "YT", rec.youtube_watch_url, target: "_blank", rel: "noopener" %> + <% end %> + · <%= button_to "Elimina", public_club_recording_path(@club, rec), method: :delete, + class: "btn-link", style: "display:inline;padding:0;border:0;background:none;color:#e53935;cursor:pointer", + form: { data: { turbo_confirm: "Eliminare definitivamente questo replay?" } } %> +
+
+ <% else %> +

Nessuna registrazione ancora. I replay compaiono al termine delle dirette Premium.

+ <% end %> +
diff --git a/backend/app/views/public/teams/_youtube.html.erb b/backend/app/views/public/clubs/_youtube.html.erb similarity index 62% rename from backend/app/views/public/teams/_youtube.html.erb rename to backend/app/views/public/clubs/_youtube.html.erb index c5823b2..14bcd52 100644 --- a/backend/app/views/public/teams/_youtube.html.erb +++ b/backend/app/views/public/clubs/_youtube.html.erb @@ -1,31 +1,32 @@ -<% yt = Youtube::TeamStatus.new(team) %> -<% return unless entitlements.youtube_enabled? %> +<% return unless entitlements&.youtube_enabled? %> +<% yt = Youtube::TeamStatus.new(entitlements_team) if entitlements_team %> +<% cred = club.youtube_credential %> -
+

YouTube Live

<% if params[:youtube] == "connected" %> -

Canale YouTube collegato con successo.

+

Canale YouTube della società collegato con successo.

<% end %> <% if entitlements.plan.youtube_mode == "matchlivetv_light" %>

- Con il piano Premium Light la diretta va sul canale ufficiale + Con il piano Premium Light le dirette YouTube di tutte le squadre vanno sul canale ufficiale Match Live TV. Non serve collegare un canale della società.

- <% if yt.selectable? %> + <% if yt&.selectable? %>

Canale pronto · seleziona «YouTube Live» nell’app mobile.

<% else %>

Canale Match Live TV in configurazione lato server. Contatta il supporto se YouTube non compare in app.

<% end %> <% elsif entitlements.premium_full? %> - <% cred = team.youtube_credential %> <% if cred %>

- Canale collegato: + Canale collegato per tutta la società: <%= cred.channel_title.presence || cred.channel_id || "YouTube" %>

- <%= button_to "Scollega canale", public_team_youtube_disconnect_path(team), +

Tutte le squadre possono trasmettere su questo canale quando scelgono il canale società in app.

+ <%= button_to "Scollega canale", public_club_youtube_disconnect_path(club), method: :delete, class: "btn btn-secondary", form: { data: { turbo_confirm: "Scollegare il canale YouTube? Le dirette useranno il canale Match Live TV finché non ricolleghi il tuo." } } %> @@ -33,13 +34,13 @@

Senza canale collegato, le dirette YouTube dall’app vanno sul canale ufficiale Match Live TV. - Puoi collegare il canale della società quando vuoi per trasmettere sul tuo profilo. + Collega qui il canale YouTube della società (vale per tutte le squadre).

- <% if yt.selectable? %> -

YouTube pronto in app (canale Match Live TV).

+ <% if yt&.selectable? %> +

YouTube pronto in app (canale Match Live TV finché non colleghi il tuo).

<% end %> <% if ENV["YOUTUBE_CLIENT_ID"].present? %> - <%= link_to "Collega il tuo canale YouTube", public_team_youtube_connect_path(team), class: "btn btn-primary" %> + <%= link_to "Collega il canale YouTube della società", public_club_youtube_connect_path(club), class: "btn btn-primary" %> <% else %>

OAuth YouTube non ancora configurato sul server.

<% end %> diff --git a/backend/app/views/public/clubs/show.html.erb b/backend/app/views/public/clubs/show.html.erb index 138303b..c548826 100644 --- a/backend/app/views/public/clubs/show.html.erb +++ b/backend/app/views/public/clubs/show.html.erb @@ -22,8 +22,38 @@ <%= link_to "Abbonamento", public_club_billing_path(@club), class: "btn btn-primary" %> <%= link_to "Nuova squadra", public_new_club_team_path(@club), class: "btn btn-secondary" %> <%= link_to "Dirette live", public_live_index_path(club_id: @club.id), class: "btn btn-secondary" %> + <% if @entitlements&.can_access_recordings? %> + <%= link_to "Archivio Replay", public_club_recordings_path(@club), class: "btn btn-secondary" %> + <% end %> + <%= render "public/clubs/youtube", + club: @club, + entitlements: @entitlements, + entitlements_team: @entitlements_team %> + + <% if @replay_stats %> +

Replay

+
+
+
<%= @replay_stats[:available_count] %>
+
disponibili
+
+
+
<%= @replay_stats[:expiring_soon_count] %>
+
in scadenza (7 gg)
+
+
+
<%= number_to_human_size(@replay_stats[:total_bytes]) %>
+
spazio occupato
+
+
+
<%= @replay_stats[:total_views] %>
+
visualizzazioni
+
+
+ <% end %> +

Squadre

<% if @teams.any? %> diff --git a/backend/app/views/public/live/index.html.erb b/backend/app/views/public/live/index.html.erb index 5c73ece..e09d93c 100644 --- a/backend/app/views/public/live/index.html.erb +++ b/backend/app/views/public/live/index.html.erb @@ -12,11 +12,17 @@

Solo le squadre di questa società: dirette in corso e partite programmate da app o sito.

+
+ <%= link_to "Replay di #{@club.name}", public_replay_index_path(club_id: @club.id), class: "btn btn-secondary" %> +
<% else %>

Dirette e partite in programma

Cerca per società, squadra, avversario o luogo: trovi le dirette attive e le partite programmate.

+
+ <%= link_to "Live passate — archivio replay", public_replay_index_path, class: "btn btn-secondary" %> +
<% end %> <% if @club && @can_schedule_match %> diff --git a/backend/app/views/public/replay/index.html.erb b/backend/app/views/public/replay/index.html.erb new file mode 100644 index 0000000..36d6a32 --- /dev/null +++ b/backend/app/views/public/replay/index.html.erb @@ -0,0 +1,90 @@ +<% content_for :title, "Replay — Match Live TV" %> +<% content_for :meta_description, "Archivio replay delle dirette sportive su Match Live TV. Cerca per società, squadra o avversario." %> + +
+ <%= link_to "← Dirette live", public_live_index_path, class: "back-link" %> + +

Live passate

+

Replay pubblici delle società sportive — riguarda le partite già trasmesse.

+ + <%= form_with url: public_replay_index_path, method: :get, local: true, class: "search-form" do %> + <% if params[:club_id].present? %> + <%= hidden_field_tag :club_id, params[:club_id] %> + <% end %> + + + <% if @query.present? || params[:club_id].present? %> + <%= link_to "Azzera", public_replay_index_path, class: "btn btn-secondary" %> + <% end %> + <% end %> + + <% if @clubs.many? %> + <%= form_with url: public_replay_index_path, method: :get, local: true, class: "replay-filter-form" do %> + <% if @query.present? %> + <%= hidden_field_tag :q, @query %> + <% end %> + + + <% end %> + <% end %> + + <% if @query.present? %> +

+ Risultati per «<%= h @query %>»<% if @filter_club %> · <%= @filter_club.name %><% end %>: + <%= @recordings.size %> replay +

+ <% end %> + + <% if @recordings.any? %> +
+ <% @recordings.each do |rec| %> + <% match = rec.stream_session.match %> + <%= link_to public_replay_path(rec.stream_session_id), class: "replay-card" do %> +
+ <% if rec.thumbnail_url %> + + <% else %> + + <% end %> + <%= rec.duration_label %> + +
+
+ <%= rec.title_or_default %> +

+ <%= match.team.club.name %> · <%= l_local(rec.recorded_at_or_fallback) %> +

+

+ <%= rec.views_label %> +

+
+ <% end %> + <% end %> +
+ <% else %> +
+ <% if @query.present? || params[:club_id].present? %> +

Nessun replay trovato con i filtri selezionati.

+

<%= link_to "Mostra tutti i replay pubblici", public_replay_index_path, class: "btn btn-secondary" %>

+ <% else %> +

Nessun replay pubblico al momento.

+

Quando una società rende pubblica una registrazione, comparirà qui.

+ <%= link_to "Vai alle dirette live", public_live_index_path, class: "btn btn-secondary", style: "margin-top:12px;display:inline-block" %> + <% end %> +
+ <% end %> +
diff --git a/backend/app/views/public/replay/show.html.erb b/backend/app/views/public/replay/show.html.erb index b25bf04..b60c0d6 100644 --- a/backend/app/views/public/replay/show.html.erb +++ b/backend/app/views/public/replay/show.html.erb @@ -1,39 +1,58 @@ -<% content_for :title, "Replay — #{@match.team.name} vs #{@match.opponent_name}" %> -<% content_for :meta_description, "Replay partita #{@match.team.name} vs #{@match.opponent_name} su Match Live TV." %> -<% content_for :robots, "noindex, nofollow" %> +<% content_for :title, "Replay — #{@recording.title_or_default}" %> +<% content_for :meta_description, "Replay #{@recording.title_or_default} su Match Live TV." %> +<% content_for :robots, @recording.publicly_listed? ? "index, follow" : "noindex, nofollow" %> -
- <%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %> +
+ <%= link_to "← Live passate", public_replay_index_path, class: "back-link" %> -

Replay

-

<%= @match.team.name %> vs <%= @match.opponent_name %>

+
+

<%= @recording.title_or_default %>

+

+ <%= @match.team.club.name %> · <%= l_local(@recording.recorded_at_or_fallback) %> + · <%= @recording.views_label %> +

+
- <% if @recording %> -
- <% if @session.hls_playback_url.present? %> - - - - <% else %> -

Registrazione indicizzata. Il playback dipende dalla retention del piano Premium.

- <% end %> - <% if @recording.expires_at %> -

Disponibile fino al <%= l_local(@recording.expires_at) %>

- <% end %> + <% if @recording.status == "processing" %> +
+

Registrazione in elaborazione. Riceverai un’email quando sarà pronta.

+
+ <% elsif @recording.ready? %> +
+ +
+ +
+

+ Durata <%= @recording.duration_label %> + <% if @recording.expires_at %> + · Disponibile fino al <%= l_local(@recording.expires_at) %> + <% end %> + · <%= @recording.publicly_listed? ? "Pubblico" : "Privato (link)" %> + <% if @recording.byte_size.to_i.positive? %> + · <%= @recording.byte_size_label %> + <% end %> +

+ <% ent = @recording.team.entitlements %> +
+ <% if ent.phone_download_enabled? && (logged_in? || @recording.unlisted?) %> + <%= link_to "Scarica MP4", public_replay_download_path(@session), class: "btn btn-primary" %> + <% end %> + <% if @recording.youtube_watch_url %> + <%= link_to "Apri su YouTube", @recording.youtube_watch_url, class: "btn btn-secondary", target: "_blank", rel: "noopener" %> + <% end %> +
+
+ <% elsif @recording.status == "failed" %> +
+

Replay non disponibile: <%= @recording.error_message.presence || "errore di elaborazione" %>.

+
+ <% else %> +
+

Replay non più disponibile.

<% end %>
diff --git a/backend/app/views/public/teams/_streaming_staff.html.erb b/backend/app/views/public/teams/_streaming_staff.html.erb index ff0b047..01fc9c4 100644 --- a/backend/app/views/public/teams/_streaming_staff.html.erb +++ b/backend/app/views/public/teams/_streaming_staff.html.erb @@ -81,20 +81,37 @@
<% end %> - <% if entitlements.can_access_recordings? && recordings.any? %> -

Archivio gare

+ <% if entitlements.can_access_recordings? %> +

Archivio replay

- - - - <% recordings.each do |rec| %> - - - - - <% end %> - -
PartitaReplay
<%= rec.stream_session.match.team.name %> vs <%= rec.stream_session.match.opponent_name %><%= link_to "Guarda", rec.replay_url %>
+ <% if recordings.any? %> + + + + <% recordings.each do |rec| %> + + + + + + + + + <% end %> + +
PartitaDataDurataScadenzaStato
<%= rec.title_or_default %><%= l_local(rec.recorded_at_or_fallback) %><%= rec.duration_label %><%= rec.expires_at ? l_local(rec.expires_at) : "—" %><%= rec.status_label %> + <% if rec.replay_url %> + <%= link_to "Guarda", rec.replay_url, target: "_blank", rel: "noopener" %> + <% else %> + — + <% end %> +
+

+ <%= link_to "Gestisci tutti i replay", public_club_recordings_path(team.club) %> +

+ <% else %> +

Nessun replay ancora. Al termine delle dirette Premium le registrazioni compaiono qui.

+ <% end %>
<% end %>
diff --git a/backend/app/views/public/teams/details.html.erb b/backend/app/views/public/teams/details.html.erb index 9d6a05d..365b7a0 100644 --- a/backend/app/views/public/teams/details.html.erb +++ b/backend/app/views/public/teams/details.html.erb @@ -92,7 +92,12 @@ <% end %> - <%= render "public/teams/youtube", team: @team, entitlements: @entitlements %> + <% if @can_manage && @entitlements.premium_full? && @entitlements.youtube_enabled? %> +

+ Il canale YouTube della società si configura dalla pagina + <%= link_to @club.name, public_club_path(@club) %> (sezione YouTube Live). +

+ <% end %> <%= render "public/teams/streaming_staff", team: @team, club: @club, can_manage: @can_manage, entitlements: @entitlements, owner_membership: @owner_membership, diff --git a/backend/app/views/recordings/replay_mailer/replay_expiring_soon.html.erb b/backend/app/views/recordings/replay_mailer/replay_expiring_soon.html.erb new file mode 100644 index 0000000..5ca87a3 --- /dev/null +++ b/backend/app/views/recordings/replay_mailer/replay_expiring_soon.html.erb @@ -0,0 +1,13 @@ +

Ciao <%= @recipient.name %>,

+ +

Il replay <%= @recording.title_or_default %> scadrà tra circa <%= @days_left %> giorni (<%= l @expires_at, format: :long %>).

+ +

Dopo la scadenza verrà eliminato automaticamente dallo storage secondo il piano della società.

+ +

+ Apri il replay + · + Archivio Replay +

+ +

Match Live TV — <%= MatchLiveTv.privacy_controller_email %>

diff --git a/backend/app/views/recordings/replay_mailer/replay_expiring_soon.text.erb b/backend/app/views/recordings/replay_mailer/replay_expiring_soon.text.erb new file mode 100644 index 0000000..5929773 --- /dev/null +++ b/backend/app/views/recordings/replay_mailer/replay_expiring_soon.text.erb @@ -0,0 +1,8 @@ +Ciao <%= @recipient.name %>, + +Il replay "<%= @recording.title_or_default %>" scade tra circa <%= @days_left %> giorni (<%= l @expires_at, format: :long %>). + +Apri: <%= @replay_url %> +Archivio: <%= MatchLiveTv.app_public_url.chomp('/') %>/clubs/<%= @club.id %>/replays + +Match Live TV diff --git a/backend/app/views/recordings/replay_mailer/replay_ready.html.erb b/backend/app/views/recordings/replay_mailer/replay_ready.html.erb new file mode 100644 index 0000000..b16b800 --- /dev/null +++ b/backend/app/views/recordings/replay_mailer/replay_ready.html.erb @@ -0,0 +1,15 @@ +

Ciao <%= @recipient.name %>,

+ +

Il replay della partita <%= @recording.title_or_default %> (<%= @club.name %>) è pronto e disponibile sulla piattaforma.

+ +

+ Guarda il replay +

+ +<% if @expires_at %> +

Disponibile fino al <%= l @expires_at, format: :long %>.

+<% end %> + +

Puoi gestire visibilità ed eliminazione dall’archivio Replay della società.

+ +

Match Live TV — <%= MatchLiveTv.privacy_controller_email %>

diff --git a/backend/app/views/recordings/replay_mailer/replay_ready.text.erb b/backend/app/views/recordings/replay_mailer/replay_ready.text.erb new file mode 100644 index 0000000..0198bea --- /dev/null +++ b/backend/app/views/recordings/replay_mailer/replay_ready.text.erb @@ -0,0 +1,12 @@ +Ciao <%= @recipient.name %>, + +Il replay "<%= @recording.title_or_default %>" (<%= @club.name %>) è pronto. + +Guarda: <%= @replay_url %> +<% if @expires_at %> +Disponibile fino al <%= l @expires_at, format: :long %>. +<% end %> + +Archivio: <%= MatchLiveTv.app_public_url.chomp('/') %>/clubs/<%= @club.id %>/replays + +Match Live TV diff --git a/backend/app/views/shared/_marketing_footer.html.erb b/backend/app/views/shared/_marketing_footer.html.erb index a43b13a..04fe1a6 100644 --- a/backend/app/views/shared/_marketing_footer.html.erb +++ b/backend/app/views/shared/_marketing_footer.html.erb @@ -10,8 +10,9 @@ <%= link_to "Privacy", public_privacy_path %> · <%= link_to "Termini", public_termini_path %> - diff --git a/backend/config/initializers/match_live_tv.rb b/backend/config/initializers/match_live_tv.rb index 1784ea0..7333f0d 100644 --- a/backend/config/initializers/match_live_tv.rb +++ b/backend/config/initializers/match_live_tv.rb @@ -96,5 +96,49 @@ module MatchLiveTv def password_reset_expiry_hours ENV.fetch("PASSWORD_RESET_EXPIRY_HOURS", "2").to_i end + + def replay_storage_configured? + replay_storage_endpoint.present? || replay_storage_local? + end + + def replay_storage_endpoint + ENV["REPLAY_STORAGE_ENDPOINT"].presence + end + + def replay_storage_bucket + ENV.fetch("REPLAY_STORAGE_BUCKET", "matchlivetv-replays") + end + + def replay_storage_region + ENV.fetch("REPLAY_STORAGE_REGION", "garage") + end + + def replay_storage_access_key_id + ENV["REPLAY_STORAGE_ACCESS_KEY_ID"].presence + end + + def replay_storage_secret_access_key + ENV["REPLAY_STORAGE_SECRET_ACCESS_KEY"].presence + end + + def replay_storage_force_path_style? + ENV.fetch("REPLAY_STORAGE_FORCE_PATH_STYLE", "true") == "true" + end + + def replay_storage_local? + replay_storage_endpoint.blank? + end + + def recordings_local_path + ENV.fetch("RECORDINGS_PATH", "/recordings") + end + + def replay_presigned_url_ttl_seconds + ENV.fetch("REPLAY_PRESIGNED_URL_TTL_SECONDS", "7200").to_i + end + + def replay_download_url_ttl_seconds + ENV.fetch("REPLAY_DOWNLOAD_URL_TTL_SECONDS", "900").to_i + end end end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 3a438b8..9c8c89d 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -24,6 +24,13 @@ Rails.application.routes.draw do get "youtube/authorize", to: "youtube#authorize" end + resources :recordings, only: %i[update destroy], controller: "recordings" do + member do + get :download + post :publish_youtube + end + end + resources :matches, only: %i[show update destroy] do resources :sessions, only: %i[create], controller: "stream_sessions" end @@ -87,7 +94,11 @@ Rails.application.routes.draw do get "live", to: "public/live#index", as: :public_live_index get "live/:id", to: "public/live#show", as: :public_live get "live/:id/status.json", to: "public/live#status", as: :public_live_status + get "replay", to: "public/replay#index", as: :public_replay_index get "replay/:id", to: "public/replay#show", as: :public_replay + get "replay/:id/stream", to: "public/replay#stream", as: :public_replay_stream + get "replay/:id/thumbnail", to: "public/replay#thumbnail", as: :public_replay_thumbnail + get "replay/:id/download", to: "public/replay#download", as: :public_replay_download get "regia/:token", to: "public/regia#show", as: :public_regia get "regia/:token/status.json", to: "public/regia#status", as: :public_regia_status @@ -121,12 +132,18 @@ Rails.application.routes.draw do get "clubs/:id/edit", to: "clubs#edit", as: :edit_club patch "clubs/:id", to: "clubs#update" get "clubs/:id/billing", to: "clubs#billing", as: :club_billing + get "clubs/:club_id/replays", to: "club_recordings#index", as: :club_recordings + patch "clubs/:club_id/replays/:id", to: "club_recordings#update", as: :club_recording + delete "clubs/:club_id/replays/:id", to: "club_recordings#destroy" + post "clubs/:club_id/replays/:id/publish_youtube", to: "club_recordings#publish_youtube", as: :club_recording_publish_youtube get "clubs/:id/billing/profile", to: "club_billing#profile", as: :club_billing_profile patch "clubs/:id/billing/profile", to: "club_billing#update_profile" post "clubs/:id/billing/cancel_subscription", to: "club_billing#cancel_subscription", as: :club_billing_cancel_subscription get "clubs/:id/billing/invoices/:invoice_id", to: "club_billing#download_invoice", as: :club_billing_invoice get "clubs/:id/checkout", to: "clubs#checkout", as: :club_checkout post "clubs/:id/portal", to: "clubs#portal", as: :club_portal + get "clubs/:id/youtube/connect", to: "clubs#youtube_connect", as: :club_youtube_connect + delete "clubs/:id/youtube/disconnect", to: "clubs#youtube_disconnect", as: :club_youtube_disconnect get "clubs/:club_id/matches/new", to: "club_matches#new", as: :new_club_match post "clubs/:club_id/matches", to: "club_matches#create", as: :club_matches get "teams/new", to: redirect("/clubs/new") diff --git a/backend/db/migrate/20260528120000_enhance_recordings_for_replay_module.rb b/backend/db/migrate/20260528120000_enhance_recordings_for_replay_module.rb new file mode 100644 index 0000000..ea9528c --- /dev/null +++ b/backend/db/migrate/20260528120000_enhance_recordings_for_replay_module.rb @@ -0,0 +1,21 @@ +class EnhanceRecordingsForReplayModule < ActiveRecord::Migration[7.2] + def change + change_table :recordings, bulk: true do |t| + t.string :title + t.string :privacy_status, null: false, default: "unlisted" + t.bigint :byte_size + t.integer :duration_secs + t.string :storage_key + t.string :storage_bucket + t.string :storage_backend, null: false, default: "local" + t.datetime :recorded_at + t.datetime :deleted_at + t.text :error_message + end + + add_index :recordings, :privacy_status + add_index :recordings, :deleted_at + add_index :recordings, %i[team_id status] + add_index :recordings, :storage_key, unique: true, where: "storage_key IS NOT NULL" + end +end diff --git a/backend/db/migrate/20260528140000_replay_enhancements.rb b/backend/db/migrate/20260528140000_replay_enhancements.rb new file mode 100644 index 0000000..b3ff43f --- /dev/null +++ b/backend/db/migrate/20260528140000_replay_enhancements.rb @@ -0,0 +1,16 @@ +class ReplayEnhancements < ActiveRecord::Migration[7.2] + def change + change_table :recordings, bulk: true do |t| + t.jsonb :metadata, null: false, default: {} + t.string :thumbnail_storage_key + t.integer :view_count, null: false, default: 0 + t.datetime :ready_notified_at + t.datetime :expiry_warning_sent_at + t.string :youtube_video_id + t.datetime :youtube_published_at + end + + add_index :recordings, :view_count + add_index :recordings, :youtube_video_id, where: "youtube_video_id IS NOT NULL" + end +end diff --git a/backend/db/migrate/20260603121000_move_youtube_credentials_to_clubs.rb b/backend/db/migrate/20260603121000_move_youtube_credentials_to_clubs.rb new file mode 100644 index 0000000..2e2eba2 --- /dev/null +++ b/backend/db/migrate/20260603121000_move_youtube_credentials_to_clubs.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class MoveYoutubeCredentialsToClubs < ActiveRecord::Migration[7.2] + def up + unless column_exists?(:youtube_credentials, :club_id) + add_reference :youtube_credentials, :club, type: :uuid, foreign_key: true + end + + if column_exists?(:youtube_credentials, :team_id) + say_with_time "Associa credenziali YouTube alle società" do + execute <<~SQL.squish + UPDATE youtube_credentials yc + SET club_id = t.club_id + FROM teams t + WHERE t.id = yc.team_id AND yc.club_id IS NULL + SQL + end + + execute <<~SQL.squish + DELETE FROM youtube_credentials yc + USING youtube_credentials newer + WHERE yc.club_id = newer.club_id + AND yc.id <> newer.id + AND yc.updated_at < newer.updated_at + SQL + + change_column_null :youtube_credentials, :club_id, false + remove_index :youtube_credentials, :team_id, if_exists: true + remove_reference :youtube_credentials, :team, foreign_key: true + end + + unless index_exists?(:youtube_credentials, :club_id) + add_index :youtube_credentials, :club_id, unique: true + end + end + + def down + return unless column_exists?(:youtube_credentials, :club_id) + + add_reference :youtube_credentials, :team, type: :uuid, foreign_key: true unless column_exists?(:youtube_credentials, :team_id) + + execute <<~SQL.squish + UPDATE youtube_credentials yc + SET team_id = ( + SELECT t.id FROM teams t + WHERE t.club_id = yc.club_id + ORDER BY t.created_at + LIMIT 1 + ) + SQL + + change_column_null :youtube_credentials, :team_id, false + remove_index :youtube_credentials, :club_id, if_exists: true + remove_reference :youtube_credentials, :club, foreign_key: true + add_index :youtube_credentials, :team_id, if_not_exists: true + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 0cd4f4a..aeed289 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_06_03_120000) do +ActiveRecord::Schema[7.2].define(version: 2026_06_03_121000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -176,9 +176,32 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_120000) do t.datetime "expires_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "title" + t.string "privacy_status", default: "unlisted", null: false + t.bigint "byte_size" + t.integer "duration_secs" + t.string "storage_key" + t.string "storage_bucket" + t.string "storage_backend", default: "local", null: false + t.datetime "recorded_at" + t.datetime "deleted_at" + t.text "error_message" + t.jsonb "metadata", default: {}, null: false + t.string "thumbnail_storage_key" + t.integer "view_count", default: 0, null: false + t.datetime "ready_notified_at" + t.datetime "expiry_warning_sent_at" + t.string "youtube_video_id" + t.datetime "youtube_published_at" + t.index ["deleted_at"], name: "index_recordings_on_deleted_at" t.index ["expires_at"], name: "index_recordings_on_expires_at" + t.index ["privacy_status"], name: "index_recordings_on_privacy_status" + t.index ["storage_key"], name: "index_recordings_on_storage_key", unique: true, where: "(storage_key IS NOT NULL)" t.index ["stream_session_id"], name: "index_recordings_on_stream_session_id", unique: true + t.index ["team_id", "status"], name: "index_recordings_on_team_id_and_status" t.index ["team_id"], name: "index_recordings_on_team_id" + t.index ["view_count"], name: "index_recordings_on_view_count" + t.index ["youtube_video_id"], name: "index_recordings_on_youtube_video_id", where: "(youtube_video_id IS NOT NULL)" end create_table "score_states", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -334,7 +357,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_120000) do end create_table "youtube_credentials", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "team_id", null: false t.text "access_token_encrypted" t.text "refresh_token_encrypted" t.datetime "expires_at" @@ -342,7 +364,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_120000) do t.string "channel_title" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["team_id"], name: "index_youtube_credentials_on_team_id" + t.uuid "club_id", null: false + t.index ["club_id"], name: "index_youtube_credentials_on_club_id" end add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" @@ -369,5 +392,5 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_120000) do add_foreign_key "teams", "clubs" add_foreign_key "user_teams", "teams" add_foreign_key "user_teams", "users" - add_foreign_key "youtube_credentials", "teams" + add_foreign_key "youtube_credentials", "clubs" end diff --git a/backend/lib/tasks/recordings.rake b/backend/lib/tasks/recordings.rake new file mode 100644 index 0000000..f6f9da2 --- /dev/null +++ b/backend/lib/tasks/recordings.rake @@ -0,0 +1,13 @@ +namespace :recordings do + desc "Elimina replay scaduti (DB + storage)" + task purge_expired: :environment do + Recordings::PurgeExpiredJob.new.perform + puts "Purge replay scaduti completata" + end + + desc "Invia email di avviso scadenza replay (7 giorni)" + task expiry_warnings: :environment do + Recordings::ExpiryWarningJob.new.perform + puts "Avvisi scadenza replay inviati" + end +end diff --git a/backend/lib/tasks/replay_e2e.rake b/backend/lib/tasks/replay_e2e.rake new file mode 100644 index 0000000..01eaf68 --- /dev/null +++ b/backend/lib/tasks/replay_e2e.rake @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +namespace :replay do + desc "Test end-to-end: segmenti finti → merge → upload Garage → verifica S3" + task e2e_garage: :environment do + abort "Garage non configurato: imposta REPLAY_STORAGE_ENDPOINT in infra/.env" if MatchLiveTv.replay_storage_local? + + team = Team.joins(:club).find { |t| t.entitlements.can_access_recordings? } + abort "Nessuna squadra Premium nel DB" unless team + + user = User.where(role: %w[coach admin]).first || User.first + abort "Nessun utente nel DB" unless user + + match = team.matches.create!( + opponent_name: "E2E Garage Test", + scheduled_at: Time.current + ) + session = StreamSession.create!( + match: match, + user: user, + platform: "matchlivetv", + status: "ended", + privacy_status: "public", + started_at: 5.minutes.ago, + ended_at: Time.current + ) + + recording = Recordings::FinalizeSession.new(session).call + abort "FinalizeSession non ha creato recording" unless recording + + segment_dir = File.join(MatchLiveTv.recordings_local_path, session.mediamtx_path_name) + FileUtils.mkdir_p(segment_dir) + segment = File.join(segment_dir, "segment-001.mp4") + ok = system( + "ffmpeg", "-y", + "-f", "lavfi", "-i", "testsrc=duration=8:size=320x240:rate=10", + "-f", "lavfi", "-i", "sine=frequency=440:duration=8", + "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-shortest", + segment, + out: File::NULL, err: File::NULL + ) + abort "ffmpeg non ha creato il segmento di test" unless ok && File.exist?(segment) + + puts "[e2e] Session #{session.id} — upload in corso..." + Recordings::UploadFromSession.new(session).call + recording.reload + + unless recording.status == "ready" && recording.storage_backend == "s3" + abort "Recording non ready/s3: status=#{recording.status} backend=#{recording.storage_backend} err=#{recording.error_message}" + end + + require "aws-sdk-s3" + client = Aws::S3::Client.new( + access_key_id: MatchLiveTv.replay_storage_access_key_id, + secret_access_key: MatchLiveTv.replay_storage_secret_access_key, + endpoint: MatchLiveTv.replay_storage_endpoint, + region: MatchLiveTv.replay_storage_region, + force_path_style: MatchLiveTv.replay_storage_force_path_style? + ) + head = client.head_object(bucket: recording.storage_bucket, key: recording.storage_key) + url = Recordings::Storage.new.presigned_get_url(key: recording.storage_key, expires_in: 120) + + puts "[e2e] OK" + puts " recording_id: #{recording.id}" + puts " storage_key: #{recording.storage_key}" + puts " byte_size: #{recording.byte_size} (S3: #{head.content_length})" + puts " thumbnail: #{recording.thumbnail_storage_key || '(nessuna)'}" + puts " presigned: #{url[0, 80]}..." + + if recording.thumbnail_storage_key.present? + client.head_object(bucket: recording.storage_bucket, key: recording.thumbnail_storage_key) + puts " thumbnail su Garage: OK" + end + end +end diff --git a/backend/public/live.css b/backend/public/live.css index 3ea06cd..bd4d5e5 100644 --- a/backend/public/live.css +++ b/backend/public/live.css @@ -356,3 +356,171 @@ border-radius: 12px; background: #000; } + +.live-main .replay-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 20px; + margin-top: 24px; +} + +.live-main a.replay-card { + display: block; + text-decoration: none; + color: #eee; + background: #14141c; + border: 1px solid #2a2a36; + border-radius: 14px; + overflow: hidden; + transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s; +} + +.live-main a.replay-card:hover { + border-color: #e53935; + transform: translateY(-3px); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45); + color: #fff; + text-decoration: none; +} + +.live-main .replay-card__media { + position: relative; + background: #0a0a0e; +} + +.live-main .replay-card__thumb { + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + background: #111; + display: block; + vertical-align: middle; +} + +.live-main .replay-card__thumb--placeholder { + display: flex; + align-items: center; + justify-content: center; + min-height: 140px; + background: linear-gradient(145deg, #1a1a24 0%, #0d0d12 100%); +} + +.live-main .replay-card__placeholder-icon { + font-size: 2rem; + color: #444; +} + +.live-main .replay-card__play { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 52px; + height: 52px; + border-radius: 50%; + background: rgba(229, 57, 53, 0.92); + color: #fff; + font-size: 1.1rem; + line-height: 52px; + text-align: center; + padding-left: 4px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + pointer-events: none; +} + +.live-main .replay-card__duration { + position: absolute; + right: 8px; + bottom: 8px; + padding: 3px 8px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.75); + color: #fff; + font-size: 0.75rem; + font-weight: 600; +} + +.live-main .replay-card__body { + padding: 12px 14px 14px; +} + +.live-main .replay-card__title { + display: block; + font-size: 1rem; + margin: 0 0 6px; + color: #fff; + line-height: 1.3; +} + +.live-main .replay-card__meta { + margin: 0; + font-size: 0.85rem; + color: #999; +} + +.live-main .replay-card__meta--sub { + margin-top: 4px; + font-size: 0.8rem; + color: #777; +} + +.live-main .replay-show__header { + margin-bottom: 20px; +} + +.live-main .replay-show__header h1 { + margin: 0 0 8px; + font-size: 1.6rem; +} + +.live-main .replay-show__meta { + margin: 0; + color: #999; + font-size: 0.95rem; +} + +.live-main .replay-show__player { + padding: 0; + overflow: hidden; + margin-bottom: 16px; +} + +.live-main .replay-show__player .replay-video { + display: block; + width: 100%; + border-radius: 12px; +} + +.live-main .replay-show__details { + padding: 16px 18px; +} + +.live-main .replay-show__details-line { + margin: 0 0 14px; + color: #aaa; + font-size: 0.9rem; + line-height: 1.5; +} + +.live-main .replay-show__actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.live-main .replay-filter-form { + margin: 12px 0 20px; + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.live-main .replay-filter-form .input { + min-width: 200px; + padding: 10px 12px; + border: 1px solid #333; + border-radius: 10px; + background: #14141c; + color: #fff; +} diff --git a/backend/public/marketing.css b/backend/public/marketing.css index 0e31423..3305d6d 100644 --- a/backend/public/marketing.css +++ b/backend/public/marketing.css @@ -1014,7 +1014,9 @@ body.nav-menu-open { overflow: hidden; } .stripe-secure--compact i { font-size: 0.95rem; color: #888; } .site-footer { border-top: 1px solid #252530; padding: 32px 0; margin-top: 40px; color: #888; font-size: 0.88rem; } .site-footer .wrap { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; } -.site-footer__stripe { flex: 1 1 100%; margin-top: 4px; } +.site-footer__legal { flex: 1 1 100%; margin-top: 4px; } +.site-footer__legal p { margin: 0 0 6px; line-height: 1.45; } +.site-footer__legal p:last-child { margin-bottom: 0; } .compare-table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 0.9rem; } .compare-table th, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; } .compare-table th { color: #aaa; font-weight: 600; } diff --git a/backend/spec/models/recording_spec.rb b/backend/spec/models/recording_spec.rb new file mode 100644 index 0000000..e8e6d07 --- /dev/null +++ b/backend/spec/models/recording_spec.rb @@ -0,0 +1,34 @@ +require "rails_helper" + +RSpec.describe Recording 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", + privacy_status: "public" + ) + end + + it "is ready when status ready and not expired" do + rec = described_class.create!( + stream_session: session, + team: team, + status: "ready", + expires_at: 1.day.from_now, + storage_key: "teams/#{team.id}/sessions/#{session.id}/replay.mp4" + ) + expect(rec).to be_ready + expect(rec.replay_url).to include("/replay/#{session.id}") + end + + it "exposes default title from match" do + rec = described_class.create!(stream_session: session, team: team, status: "processing") + expect(rec.title_or_default).to eq("Team vs Rival") + end +end diff --git a/backend/spec/services/recordings/access_policy_spec.rb b/backend/spec/services/recordings/access_policy_spec.rb new file mode 100644 index 0000000..bb33790 --- /dev/null +++ b/backend/spec/services/recordings/access_policy_spec.rb @@ -0,0 +1,35 @@ +require "rails_helper" + +RSpec.describe Recordings::AccessPolicy 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 + let!(:recording) do + Recording.create!( + stream_session: session, + team: team, + status: "ready", + privacy_status: "unlisted", + expires_at: 10.days.from_now, + storage_key: "key" + ) + end + + it "allows unlisted replay without login" do + expect(described_class.new(recording, viewer: nil).allowed?).to be true + end + + it "allows public replay without login" do + recording.update!(privacy_status: "public") + expect(described_class.new(recording, viewer: nil).allowed?).to be true + end + + it "denies expired replay" do + recording.update!(expires_at: 1.day.ago) + expect(described_class.new(recording, viewer: nil).allowed?).to be false + end +end diff --git a/backend/spec/services/recordings/club_stats_spec.rb b/backend/spec/services/recordings/club_stats_spec.rb new file mode 100644 index 0000000..878c0bf --- /dev/null +++ b/backend/spec/services/recordings/club_stats_spec.rb @@ -0,0 +1,26 @@ +require "rails_helper" + +RSpec.describe Recordings::ClubStats 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 + + it "aggregates ready replays" do + Recording.create!( + stream_session: session, + team: team, + status: "ready", + byte_size: 100.megabytes, + expires_at: 3.days.from_now, + storage_key: "a" + ) + stats = described_class.new(club).call + expect(stats[:available_count]).to eq(1) + expect(stats[:expiring_soon_count]).to eq(1) + expect(stats[:total_bytes]).to eq(100.megabytes) + end +end diff --git a/backend/spec/services/recordings/finalize_session_spec.rb b/backend/spec/services/recordings/finalize_session_spec.rb new file mode 100644 index 0000000..6932757 --- /dev/null +++ b/backend/spec/services/recordings/finalize_session_spec.rb @@ -0,0 +1,35 @@ +require "rails_helper" + +RSpec.describe Recordings::FinalizeSession 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", + privacy_status: "public", + ended_at: Time.current + ) + end + + before do + load Rails.root.join("db/seeds/plans.rb") + Billing::AssignPlan.call(club: club, plan_slug: "premium_light") + end + + it "creates processing recording with retention" do + rec = described_class.new(session).call + expect(rec.status).to eq("processing") + expect(rec.privacy_status).to eq("public") + expect(rec.expires_at).to be > 29.days.from_now + end + + it "skips free plan" do + Billing::AssignPlan.call(club: club, plan_slug: "free") + expect(described_class.new(session).call).to be_nil + end +end diff --git a/backend/spec/services/recordings/increment_views_spec.rb b/backend/spec/services/recordings/increment_views_spec.rb new file mode 100644 index 0000000..19630a2 --- /dev/null +++ b/backend/spec/services/recordings/increment_views_spec.rb @@ -0,0 +1,24 @@ +require "rails_helper" + +RSpec.describe Recordings::IncrementViews 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: "c@test.com", name: "C", password: "password123", role: "coach") } + let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) } + let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended") } + let!(:recording) do + Recording.create!( + stream_session: session, + team: team, + status: "ready", + expires_at: 10.days.from_now, + storage_key: "key", + view_count: 2 + ) + end + + it "increments view_count" do + described_class.new(recording).call + expect(recording.reload.view_count).to eq(3) + end +end diff --git a/backend/spec/services/recordings/notify_ready_spec.rb b/backend/spec/services/recordings/notify_ready_spec.rb new file mode 100644 index 0000000..cf129eb --- /dev/null +++ b/backend/spec/services/recordings/notify_ready_spec.rb @@ -0,0 +1,30 @@ +require "rails_helper" + +RSpec.describe Recordings::NotifyReady do + let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:owner) { User.create!(email: "owner@test.com", name: "Owner", password: "password123", role: "coach") } + let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } + let!(:user) { User.create!(email: "c@test.com", name: "C", password: "password123", role: "coach") } + let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) } + let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended") } + + before do + ClubMembership.create!(club: club, user: owner, role: "owner") + end + + it "sends email and marks notified" do + recording = Recording.create!( + stream_session: session, + team: team, + status: "ready", + expires_at: 10.days.from_now, + storage_key: "key" + ) + + expect { + described_class.new(recording).call + }.to change { ActionMailer::Base.deliveries.size }.by(1) + + expect(recording.reload.ready_notified_at).to be_present + end +end diff --git a/docs/APP_YOUTUBE_LIVE.md b/docs/APP_YOUTUBE_LIVE.md index 6416cf3..e0e1af8 100644 --- a/docs/APP_YOUTUBE_LIVE.md +++ b/docs/APP_YOUTUBE_LIVE.md @@ -16,7 +16,7 @@ Percorso per il **responsabile trasmissione** invitato via email: accede in app | Premium Full, canale collegato | Canale **della società** | | Premium Full, canale non collegato | **Match Live TV** (default, come Light) | -Il collegamento del canale società è **opzionale** su Full: si fa dal sito (dettaglio squadra) o in futuro da app; finché non c’è OAuth, l’app trasmette sul canale piattaforma. +Il collegamento del canale società è **opzionale** su Full: si fa dal sito (pagina società → sezione YouTube Live) o in futuro da app; finché non c’è OAuth, l’app trasmette sul canale piattaforma. ## 1. Invito @@ -31,7 +31,7 @@ API: `GET /api/v1/invitations/:token`, `POST /api/v1/invitations/:token/accept` Solo se la società vuole il **proprio** canale invece di Match Live TV: -1. Sul sito: dettaglio squadra → **Collega il tuo canale YouTube** +1. Sul sito: pagina società (`/clubs/:id`) → **Collega il canale YouTube della società** 2. Oppure (se abilitato) OAuth da app al tocco YouTube quando non c’è ancora canale piattaforma ## 3. Scegli la partita diff --git a/docs/REPLAY_MODULE.md b/docs/REPLAY_MODULE.md new file mode 100644 index 0000000..3814ab7 --- /dev/null +++ b/docs/REPLAY_MODULE.md @@ -0,0 +1,105 @@ +# Modulo Replay — Match Live TV + +## Panoramica + +Al termine di ogni diretta Premium, MediaMTX registra i segmenti. Sidekiq unisce i segmenti, genera thumbnail, carica su **Garage (S3-compatible)** e attiva i servizi di notifica, statistiche e (opzionale) republicazione YouTube. + +## Piani e retention + +| Piano | Registrazione | Retention | Download MP4 | YouTube VOD | +|-------|---------------|-----------|--------------|-------------| +| Free | No | — | No | No | +| Premium Light | Sì | 30 giorni | Sì | No | +| Premium Full | Sì | 90 giorni | Sì | Sì (auto se diretta YouTube) | + +## Funzionalità implementate + +### 1. Registrazione automatica +Pipeline: `Sessions::Stop` → `FinalizeSession` → `UploadJob` → storage. + +### 2. Retention e purge +`Recordings::PurgeExpiredJob` / `rails recordings:purge_expired` + +### 3. Archivio Replay +- Web: `/clubs/:id/replays` (gestione società) +- Pubblico: `/replay` con filtro società/squadra +- App: schermata Replay + +### 4. Riproduzione +- Player MP4: `/replay/:id` + stream `/replay/:id/stream` +- Contatore visualizzazioni su ogni play (`view_count`) + +### 5. Visibilità +Pubblico / Privato (unlisted), modificabile dall’admin società. + +### 6. Eliminazione anticipata +`Recordings::Delete` — DB + video + thumbnail su storage. + +### 7. Dashboard KPI +Replay disponibili, in scadenza (7 gg), spazio occupato, **visualizzazioni totali**. + +### 8. Miglioramenti Premium + +| Feature | Descrizione | +|---------|-------------| +| **Email replay pronto** | A owner e membri società quando status → `ready` | +| **Email scadenza** | 7 giorni prima di `expires_at` (`rails recordings:expiry_warnings`) | +| **Thumbnail** | Frame ffmpeg, URL `/replay/:id/thumbnail` | +| **Download MP4** | Premium, link presigned 15 min o `/replay/:id/download` | +| **Statistiche views** | `view_count` su ogni replay | +| **metadata JSONB** | `source_platform`, `auto_publish_youtube`, `ai: {}` per estensioni | +| **YouTube VOD** | Premium Full: upload automatico se diretta era su YouTube; manuale da archivio | + +## Storage + +```env +REPLAY_STORAGE_ENDPOINT=http://garage:3900 +REPLAY_STORAGE_BUCKET=matchlivetv-replays +REPLAY_STORAGE_ACCESS_KEY_ID=... +REPLAY_STORAGE_SECRET_ACCESS_KEY=... +REPLAY_DOWNLOAD_URL_TTL_SECONDS=900 +``` + +Senza endpoint S3: storage locale `/recordings/replays/`. + +### Dev locale con Garage + +```bash +cd infra +docker compose up -d # include garage +bash scripts/setup_garage_replays.sh # layout, bucket, chiave → .env +docker compose up -d rails sidekiq + +# Verifica pipeline (segmento finto → Garage) +docker compose exec -T rails bundle exec rails replay:e2e_garage +``` + +**Credenziali in `.env`:** non compilarle a mano in `.env.example`. Lo script scrive in `infra/.env`: +- `REPLAY_STORAGE_ACCESS_KEY_ID` = Key ID Garage (es. `GK...`) +- `REPLAY_STORAGE_SECRET_ACCESS_KEY` = Secret mostrato una sola volta alla creazione chiave + +## Cron consigliati (produzione) + +```cron +0 3 * * * cd /opt/matchlivetv/infra && docker compose exec -T rails bundle exec rails recordings:purge_expired +0 8 * * * cd /opt/matchlivetv/infra && docker compose exec -T rails bundle exec rails recordings:expiry_warnings +``` + +## API mobile + +- `GET /teams/:id/recordings` — lista con thumbnail, views, download_enabled +- `GET /recordings/:id/download` — URL download temporaneo +- `POST /recordings/:id/publish_youtube` — coda republicazione YouTube + +## Estensioni future (metadata.ai) + +Campo `metadata["ai"]` predisposto per: +- highlight automatici +- trascrizioni +- articoli generati +- clip / Shorts + +Esempio aggiornamento via API: +```json +{ "recording": { "metadata": { "ai": { "transcript_status": "queued" } } } } } +``` diff --git a/docs/YOUTUBE_SETUP.md b/docs/YOUTUBE_SETUP.md index 1c0febf..8fdc1ef 100644 --- a/docs/YOUTUBE_SETUP.md +++ b/docs/YOUTUBE_SETUP.md @@ -76,7 +76,7 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key ## 5. Verifica -- Premium Full: dopo collegamento, `GET /api/v1/teams/:id` → `youtube_connected: true`, `youtube_selectable: true` +- Premium Full: collegamento OAuth dalla pagina società (`/clubs/:id`); credenziali uniche per club. Dopo collegamento, `GET /api/v1/teams/:id` → `youtube_connected: true`, `youtube_selectable: true` - Premium Light: con `YOUTUBE_PLATFORM_REFRESH_TOKEN` → `youtube_selectable: true` anche senza OAuth squadra - Avvia diretta YouTube da app → in admin session compare broadcast ID → link YouTube Studio diff --git a/infra/.env.example b/infra/.env.example index ace08b0..7cbb4c5 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -26,3 +26,24 @@ STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID= # Legacy (equivale a YEARLY se le righe sopra sono vuote) STRIPE_PREMIUM_LIGHT_PRICE_ID= STRIPE_PREMIUM_FULL_PRICE_ID= + +# --- Replay storage (dopo ogni diretta Premium) --- +# Dev locale con Docker: Garage parte con `docker compose up` (servizio garage). +# Bootstrap una tantum: bash infra/scripts/setup_garage_replays.sh → aggiorna .env con le chiavi. +# MODALITÀ A — solo filesystem: lascia REPLAY_STORAGE_ENDPOINT vuoto (volume /recordings/replays/). +# MODALITÀ B — Garage/S3: compila tutte le righe sotto (endpoint rete Docker: http://garage:3900). +REPLAY_STORAGE_ENDPOINT=http://garage:3900 +REPLAY_STORAGE_BUCKET=matchlivetv-replays +REPLAY_STORAGE_REGION=garage +# Credenziali S3: NON le inventi a mano. Le genera Garage con lo script di bootstrap: +# cd infra && bash scripts/setup_garage_replays.sh +# Lo script crea la chiave "replay-uploader" e scrive Key ID + Secret in infra/.env (non in questo file). +# ACCESS_KEY_ID = "Key ID" mostrato da Garage (es. GK6619e25d53883a7bc3f75755) +# SECRET_ACCESS_KEY = "Secret key" mostrata UNA SOLA VOLTA alla creazione della chiave +REPLAY_STORAGE_ACCESS_KEY_ID= +REPLAY_STORAGE_SECRET_ACCESS_KEY= +# true = obbligatorio per Garage/MinIO (path-style). Non cambiare se usi Garage. +REPLAY_STORAGE_FORCE_PATH_STYLE=true +# Opzionale: durata link firmati per play/download (secondi) +# REPLAY_PRESIGNED_URL_TTL_SECONDS=7200 +# REPLAY_DOWNLOAD_URL_TTL_SECONDS=900 diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index 1582800..2b32e0f 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -94,6 +94,13 @@ services: STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_FULL_PRICE_ID:-}} STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-} STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-} + RECORDINGS_PATH: /recordings + REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-} + REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays} + REPLAY_STORAGE_REGION: ${REPLAY_STORAGE_REGION:-garage} + REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-} + REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-} + REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true} ports: - "3000:3000" # Solo LAN — NPM proxy HTTPS depends_on: @@ -133,9 +140,18 @@ services: YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-} HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888} APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000} + RECORDINGS_PATH: /recordings + REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-} + REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays} + REPLAY_STORAGE_REGION: ${REPLAY_STORAGE_REGION:-garage} + REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-} + REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-} + REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true} depends_on: rails: condition: service_healthy + volumes: + - recordings:/recordings volumes: postgres_data: diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 180b750..a2159d1 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -81,6 +81,13 @@ services: STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_FULL_PRICE_ID:-}} STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-} STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-} + RECORDINGS_PATH: /recordings + REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-http://garage:3900} + REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays} + REPLAY_STORAGE_REGION: ${REPLAY_STORAGE_REGION:-garage} + REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-} + REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-} + REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true} ports: - "3000:3000" depends_on: @@ -90,6 +97,8 @@ services: condition: service_healthy mediamtx: condition: service_started + garage: + condition: service_started volumes: - ../backend:/app - recordings:/recordings @@ -119,13 +128,33 @@ services: JWT_SECRET: ${JWT_SECRET:-matchlivetv_jwt_dev_secret} YOUTUBE_CLIENT_ID: ${YOUTUBE_CLIENT_ID:-} YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-} + RECORDINGS_PATH: /recordings + REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-http://garage:3900} + REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays} + REPLAY_STORAGE_REGION: ${REPLAY_STORAGE_REGION:-garage} + REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-} + REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-} + REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true} depends_on: rails: condition: service_healthy + garage: + condition: service_started volumes: - ../backend:/app + - recordings:/recordings - bundle_cache:/usr/local/bundle + garage: + image: dxflrs/garage:v1.0.1 + ports: + - "3900:3900" + volumes: + - garage_meta:/var/lib/garage/meta + - garage_data:/var/lib/garage/data + - ./garage/garage.toml:/etc/garage.toml:ro + command: ["/garage", "-c", "/etc/garage.toml", "server"] + nginx: image: nginx:alpine ports: @@ -140,3 +169,5 @@ volumes: redis_data: recordings: bundle_cache: + garage_meta: + garage_data: diff --git a/infra/garage/garage.toml b/infra/garage/garage.toml new file mode 100644 index 0000000..b300f79 --- /dev/null +++ b/infra/garage/garage.toml @@ -0,0 +1,26 @@ +# Garage single-node dev config for Match Live TV replay storage +# Docs: https://garagehq.deuxfleurs.fr/documentation/quick-start/ + +metadata_dir = "/var/lib/garage/meta" +data_dir = "/var/lib/garage/data" +db_engine = "sqlite" + +replication_factor = 1 + +rpc_bind_addr = "[::]:3901" +rpc_public_addr = "garage:3901" +# 32 byte hex (64 caratteri) — solo dev locale +rpc_secret = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + +[s3_api] +api_bind_addr = "[::]:3900" +s3_region = "garage" +root_domain = ".s3.garage.localhost" + +[s3_web] +bind_addr = "[::]:3902" +root_domain = ".web.garage.localhost" + +[admin] +api_bind_addr = "[::]:3903" +admin_token = "dev-garage-admin-token-change-in-production" diff --git a/infra/scripts/setup_garage_replays.sh b/infra/scripts/setup_garage_replays.sh new file mode 100755 index 0000000..b4f09c5 --- /dev/null +++ b/infra/scripts/setup_garage_replays.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# Bootstrap Garage (S3) per replay Match Live TV — locale o produzione +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +GARAGE_CONTAINER="${GARAGE_CONTAINER:-infra-garage-1}" +BUCKET="${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}" +KEY_NAME="${REPLAY_STORAGE_KEY_NAME:-replay-uploader}" +CREDS_FILE="${ROOT}/garage/dev-credentials.env" +ENV_FILE="${ROOT}/.env" + +gexec() { + docker exec "$GARAGE_CONTAINER" /garage "$@" +} + +wait_garage() { + echo "Attendo Garage..." + for _ in $(seq 1 30); do + if docker exec "$GARAGE_CONTAINER" /garage status >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + echo "Garage non risponde. Avvia: cd $ROOT && docker compose up -d garage" + exit 1 +} + +ensure_layout() { + local node_id + node_id="$(gexec status 2>/dev/null | awk '/^[0-9a-f]{16,}/{print $1; exit}')" + if [ -z "$node_id" ]; then + echo "Impossibile leggere node id da garage status" + gexec status || true + exit 1 + fi + echo "Node id: $node_id" + gexec layout assign -z dc1 -c 1G "$node_id" 2>/dev/null || true + gexec layout apply --version 1 2>/dev/null || gexec layout apply 2>/dev/null || true +} + +ensure_key() { + if gexec key info "$KEY_NAME" >/dev/null 2>&1; then + echo "Chiave $KEY_NAME già presente" + return 0 + fi + echo "Creazione chiave $KEY_NAME..." + local out + out="$(gexec key create "$KEY_NAME" 2>&1)" + echo "$out" + KEY_SECRET="$(echo "$out" | awk -F': ' '/Secret key/{print $2; exit}' | tr -d ' ')" + if [ -z "$KEY_SECRET" ]; then + KEY_SECRET="$(echo "$out" | grep -i secret | awk '{print $NF}' | head -1)" + fi + export KEY_SECRET +} + +ensure_bucket() { + echo "Bucket $BUCKET..." + gexec bucket create "$BUCKET" 2>/dev/null || true + gexec bucket allow \ + --read --write \ + --owner \ + "$BUCKET" \ + --key "$KEY_NAME" 2>/dev/null || \ + gexec bucket allow --read --write "$BUCKET" --key "$KEY_NAME" 2>/dev/null || true +} + +write_credentials() { + local key_id + key_id="$(gexec key info "$KEY_NAME" 2>/dev/null | awk -F': ' '/Key ID|key id/{print $2; exit}' | tr -d ' ')" + if [ -z "$key_id" ]; then + key_id="$(gexec key info "$KEY_NAME" 2>/dev/null | grep -i 'key' | head -1 | awk '{print $NF}')" + fi + + mkdir -p "${ROOT}/garage" + cat > "$CREDS_FILE" </dev/null || touch "$ENV_FILE" + fi + if grep -q '^REPLAY_STORAGE_ENDPOINT=' "$ENV_FILE" 2>/dev/null; then + sed -i 's|^REPLAY_STORAGE_ENDPOINT=.*|REPLAY_STORAGE_ENDPOINT=http://garage:3900|' "$ENV_FILE" + sed -i "s|^REPLAY_STORAGE_BUCKET=.*|REPLAY_STORAGE_BUCKET=${BUCKET}|" "$ENV_FILE" + sed -i 's|^REPLAY_STORAGE_REGION=.*|REPLAY_STORAGE_REGION=garage|' "$ENV_FILE" + sed -i "s|^REPLAY_STORAGE_ACCESS_KEY_ID=.*|REPLAY_STORAGE_ACCESS_KEY_ID=${key_id}|" "$ENV_FILE" + sed -i 's|^REPLAY_STORAGE_FORCE_PATH_STYLE=.*|REPLAY_STORAGE_FORCE_PATH_STYLE=true|' "$ENV_FILE" + if [ -n "$secret" ]; then + if grep -q '^REPLAY_STORAGE_SECRET_ACCESS_KEY=' "$ENV_FILE" 2>/dev/null; then + sed -i "s|^REPLAY_STORAGE_SECRET_ACCESS_KEY=.*|REPLAY_STORAGE_SECRET_ACCESS_KEY=${secret}|" "$ENV_FILE" + else + echo "REPLAY_STORAGE_SECRET_ACCESS_KEY=${secret}" >> "$ENV_FILE" + fi + fi + else + cat >> "$ENV_FILE" < context.go('/matches'), @@ -41,15 +43,21 @@ class ArchiveScreen extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - 'Archivio replay con Premium Light o Full', + 'Replay disponibili con Premium Light o Full', style: theme.textTheme.titleMedium, textAlign: TextAlign.center, ), + const SizedBox(height: 8), + Text( + 'Light: 30 giorni · Full: 90 giorni', + style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white70), + textAlign: TextAlign.center, + ), const SizedBox(height: 16), FilledButton( onPressed: () => showPremiumRequiredDialog( context, - message: 'Salva e rivedi le gare per 90 giorni con il piano Premium.', + message: 'Salva e rivedi le gare con il piano Premium.', billingUrl: team.billingUrl, ), style: FilledButton.styleFrom( @@ -71,7 +79,15 @@ class ArchiveScreen extends ConsumerWidget { error: (e, _) => Center(child: Text('$e')), data: (recs) { if (recs.isEmpty) { - return const Center(child: Text('Nessuna gara in archivio')); + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'Nessun replay ancora.\nAl termine delle dirette Premium le registrazioni compaiono qui.', + textAlign: TextAlign.center, + ), + ), + ); } final df = DateFormat('d MMM yyyy · HH:mm', 'it_IT'); return ListView.builder( @@ -79,27 +95,68 @@ class ArchiveScreen extends ConsumerWidget { itemCount: recs.length, itemBuilder: (context, i) { final r = recs[i]; + final when = r.recordedAt ?? r.endedAt; + final subtitle = [ + if (when != null) df.format(when.toLocal()), + if (r.durationLabel != null) r.durationLabel, + if (r.viewsLabel != null) r.viewsLabel, + r.statusLabel, + if (r.expiresAt != null) + 'Scade ${DateFormat('d MMM', 'it_IT').format(r.expiresAt!.toLocal())}', + ].whereType().join(' · '); + return Card( color: AppTheme.surface, child: ListTile( - title: Text('${r.teamName} vs ${r.opponentName}'), - subtitle: Text( - r.endedAt != null ? df.format(r.endedAt!.toLocal()) : '—', - ), - trailing: const Icon(Icons.play_circle_outline), - onTap: () async { - final url = r.replayUrl; - if (url == null) return; - final uri = Uri.parse(url); - if (await canLaunchUrl(uri)) { - await launchUrl( - uri, - mode: team.canDownloadOnPhone - ? LaunchMode.externalApplication - : LaunchMode.inAppBrowserView, - ); - } - }, + leading: r.thumbnailUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network( + r.thumbnailUrl!, + width: 72, + height: 40, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.movie), + ), + ) + : const Icon(Icons.movie_outlined, size: 40), + title: Text(r.title), + subtitle: Text(subtitle), + isThreeLine: true, + trailing: r.isProcessing + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : PopupMenuButton( + onSelected: (action) => _onMenuAction( + context, + ref, + action, + r, + team.canDownloadOnPhone, + ), + itemBuilder: (_) => [ + if (r.isReady) + const PopupMenuItem( + value: 'play', + child: Text('Guarda replay'), + ), + if (r.isReady && r.downloadEnabled && team.canDownloadOnPhone) + const PopupMenuItem( + value: 'download', + child: Text('Scarica MP4'), + ), + if (r.youtubeWatchUrl != null) + const PopupMenuItem( + value: 'youtube', + child: Text('Apri su YouTube'), + ), + ], + ), + onTap: r.isReady ? () => _openReplay(r.replayUrl) : null, ), ); }, @@ -110,4 +167,50 @@ class ArchiveScreen extends ConsumerWidget { ), ); } + + Future _openReplay(String? url) async { + if (url == null) return; + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.inAppBrowserView); + } + } + + Future _onMenuAction( + BuildContext context, + WidgetRef ref, + String action, + RecordingItem r, + bool canDownload, + ) async { + switch (action) { + case 'play': + await _openReplay(r.replayUrl); + break; + case 'download': + if (!canDownload) return; + try { + final url = await ref.read(apiClientProvider).fetchRecordingDownloadUrl(r.id); + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Download: $e')), + ); + } + } + break; + case 'youtube': + final yt = r.youtubeWatchUrl; + if (yt == null) return; + final uri = Uri.parse(yt); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + break; + } + } } diff --git a/mobile/lib/shared/api_client.dart b/mobile/lib/shared/api_client.dart index a04eda7..8575799 100644 --- a/mobile/lib/shared/api_client.dart +++ b/mobile/lib/shared/api_client.dart @@ -112,6 +112,13 @@ class ApiClient { .toList(); } + Future fetchRecordingDownloadUrl(String recordingId) async { + final response = await _dio.get>( + '/recordings/$recordingId/download', + ); + return response.data!['download_url'] as String; + } + // --- Matches --- Future> fetchMatches(String teamId) async { diff --git a/mobile/lib/shared/models/recording_item.dart b/mobile/lib/shared/models/recording_item.dart index d87d381..0649e98 100644 --- a/mobile/lib/shared/models/recording_item.dart +++ b/mobile/lib/shared/models/recording_item.dart @@ -2,31 +2,72 @@ class RecordingItem { const RecordingItem({ required this.id, required this.sessionId, + required this.title, required this.opponentName, required this.teamName, + this.status = 'ready', + this.statusLabel = 'Disponibile', + this.privacyStatus = 'unlisted', this.endedAt, + this.recordedAt, this.replayUrl, + this.playbackUrl, + this.thumbnailUrl, + this.durationLabel, + this.viewCount = 0, + this.viewsLabel, + this.downloadEnabled = false, + this.youtubeWatchUrl, this.expiresAt, }); final String id; final String sessionId; + final String title; final String opponentName; final String teamName; + final String status; + final String statusLabel; + final String privacyStatus; final DateTime? endedAt; + final DateTime? recordedAt; final String? replayUrl; + final String? playbackUrl; + final String? thumbnailUrl; + final String? durationLabel; + final int viewCount; + final String? viewsLabel; + final bool downloadEnabled; + final String? youtubeWatchUrl; final DateTime? expiresAt; + bool get isReady => status == 'ready'; + bool get isProcessing => status == 'processing'; + factory RecordingItem.fromJson(Map json) { return RecordingItem( id: json['id'] as String, sessionId: json['session_id'] as String, + title: json['title'] as String? ?? '${json['team_name']} vs ${json['opponent_name']}', opponentName: json['opponent_name'] as String, teamName: json['team_name'] as String, + status: json['status'] as String? ?? 'ready', + statusLabel: json['status_label'] as String? ?? 'Disponibile', + privacyStatus: json['privacy_status'] as String? ?? 'unlisted', endedAt: json['ended_at'] != null ? DateTime.parse(json['ended_at'] as String) : null, + recordedAt: json['recorded_at'] != null + ? DateTime.parse(json['recorded_at'] as String) + : null, replayUrl: json['replay_url'] as String?, + playbackUrl: json['playback_url'] as String?, + thumbnailUrl: json['thumbnail_url'] as String?, + durationLabel: json['duration_label'] as String?, + viewCount: json['view_count'] as int? ?? 0, + viewsLabel: json['views_label'] as String?, + downloadEnabled: json['download_enabled'] as bool? ?? false, + youtubeWatchUrl: json['youtube_watch_url'] as String?, expiresAt: json['expires_at'] != null ? DateTime.parse(json['expires_at'] as String) : null,