Files
MatchLiveTv/backend/app/models/recording.rb
T
eminuxandCursor 05ef56c56d Aggiunge replay YouTube temporaneo, pull registrazioni dai CPX e snapshot ingest in admin.
Così overflow Hetzner e VOD YouTube restano in archivio dopo lo spegnimento del nodo, e la colonna ingest non si svuota.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 13:01:41 +02:00

230 lines
6.3 KiB
Ruby

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
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 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 normalize_privacy_status
self.privacy_status = "public" if privacy_status == "private"
self.privacy_status = "unlisted" if privacy_status.blank?
end
end