class Recording < ApplicationRecord STATUSES = %w[processing ready expired failed].freeze PRIVACY_STATUSES = %w[public unlisted].freeze STORAGE_BACKENDS = %w[local s3].freeze STORAGE_POLICIES = %w[temporary retained none].freeze REPLAY_SOURCES = %w[youtube matchlivetv none].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 } validates :storage_policy, inclusion: { in: STORAGE_POLICIES } 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(storage_policy: "retained") .where(status: %w[ready failed]) .where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) } scope :temporary_media_pending_purge, lambda { not_deleted .where(storage_policy: "temporary") .where(local_media_purged_at: nil) .where("temp_expires_at IS NOT NULL AND temp_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 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? && storage_key.present? && stream_session_id.present? "#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/stream" end def thumbnail_url return youtube_thumbnail_url if thumbnail_storage_key.blank? && youtube_video_id.present? return nil unless thumbnail_storage_key.present? && stream_session_id.present? "#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/thumbnail" end def youtube_thumbnail_url return nil if youtube_video_id.blank? return nil if youtube_video_id.to_s.start_with?("mock_") meta_url = metadata.is_a?(Hash) ? metadata.dig("youtube", "thumbnail_url") : nil return meta_url if meta_url.present? "https://i.ytimg.com/vi/#{youtube_video_id}/hqdefault.jpg" end def download_api_path return nil unless ready? && storage_key.present? "/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 temporary_storage? storage_policy == "temporary" end def retained_storage? storage_policy == "retained" end def local_media_purged? local_media_purged_at.present? end # Sorgente fisica del player in archivio (non lo storage policy). def replay_source return "youtube" if youtube_watch_url.present? || (ready? && youtube_video_id.present?) return "matchlivetv" if ready? && storage_key.present? "none" end def available_in_archive? ready? && (youtube_watch_url.present? || youtube_video_id.present? || storage_key.present?) end def ready? 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 match = stream_session&.match return title.presence || "Replay" unless match generated = match.matchup_label stored = title.to_s.strip return generated if stored.blank? return generated if stale_auto_title?(match, stored) stored end def default_title match = stream_session&.match return "Replay" unless match match.matchup_label 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 source_platform metadata.is_a?(Hash) ? metadata["source_platform"].presence : nil end def source_platform_label case source_platform when "youtube" then "YouTube" when "matchlivetv" then "Match Live TV" else "—" end end def playable_on_site? available_in_archive? end def days_until_expiry return nil unless expires_at ((expires_at - Time.current) / 1.day).ceil end def status_label return I18n.t("recordings.status.deleted") if deleted? return I18n.t("recordings.status.expired") if status == "expired" return I18n.t("recordings.status.failed") if status == "failed" return I18n.t("recordings.status.processing") if status == "processing" return I18n.t("recordings.status.expiring_soon") if expires_at.present? && expires_at <= 7.days.from_now I18n.t("recordings.status.available") end private def stale_auto_title?(match, stored) return false unless match.tournament_match? team_name = match.team&.name.to_s return false if team_name.blank? [ "#{team_name} vs #{match.opponent_name}", "#{team_name} vs #{match.away_display_name}" ].include?(stored) end def normalize_privacy_status self.privacy_status = "public" if privacy_status == "private" self.privacy_status = "unlisted" if privacy_status.blank? end end