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 %> +
+Nessuna squadra registrata.
@@ -18,32 +30,14 @@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" %> + +Gestisci registrazioni, visibilità, download e pubblicazione YouTube.
+ +| + | Titolo | +Data | +Durata | +Views | +Scadenza | +Stato | +Visibilità | ++ |
|---|---|---|---|---|---|---|---|---|
|
+ <% if rec.thumbnail_url %>
+ |
+ <%= 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?" } } %> + | +
Nessuna registrazione ancora. I replay compaiono al termine delle dirette Premium.
+ <% end %> +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 %> +Solo le squadre di questa società: dirette in corso e partite programmate da app o sito.
+Cerca per società, squadra, avversario o luogo: trovi le dirette attive e le partite programmate.
+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? %> +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 %> +<%= @match.team.name %> vs <%= @match.opponent_name %>
+Registrazione indicizzata. Il playback dipende dalla retention del piano Premium.
- <% end %> - <% if @recording.expires_at %> - - <% end %> + <% if @recording.status == "processing" %> +Registrazione in elaborazione. Riceverai un’email quando sarà pronta.
++ 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 %> +Replay non disponibile: <%= @recording.error_message.presence || "errore di elaborazione" %>.
+Replay non più disponibile.
| Partita | Replay |
|---|---|
| <%= rec.stream_session.match.team.name %> vs <%= rec.stream_session.match.opponent_name %> | -<%= link_to "Guarda", rec.replay_url %> | -
| Partita | Data | Durata | Scadenza | Stato | |
|---|---|---|---|---|---|
| <%= 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 %>+ 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 %>