Class: Video
- Inherits:
-
DigitalAsset
- Object
- ActiveRecord::Base
- ApplicationRecord
- DigitalAsset
- Video
- Defined in:
- app/models/video.rb
Overview
== Schema Information
Table name: digital_assets
Database name: primary
id :integer not null, primary key
ai_metadata_suggestions :jsonb
ai_visual_description :text
air_date :date
asset :jsonb
attachment_format :string(10)
attachment_height :integer
attachment_mime_type :string
attachment_name :string
attachment_size :integer
attachment_uid :string
attachment_width :integer
background_color :string
category :string(255)
cloudflare_data :jsonb not null
cloudflare_uid :string
duration_in_seconds :integer
expanded_description :text
fingerprint :bigint
fingerprint_legacy :string
image_colorspace :string
image_dpi :integer
inactive :boolean default(FALSE), not null
linked_assets_uids :string default([]), is an Array
locales :string default([]), not null, is an Array
location :string
merged_from_ids :integer default([]), is an Array
meta_description :text
meta_keywords :string
meta_title :string(255)
notes :text
position :integer default(100), not null
poster_format :string
poster_mime_type :string
poster_name :string
poster_offset :integer
poster_uid :string
reference_number :string
series :string
slug :string(140)
source :string
structured_transcript_json :jsonb
sub_header :string(255)
thumbnail_url :string
title :string(255)
transcribed_at :datetime
transcript :text
transcription_state :integer default("pending")
translations :jsonb
type :string
url :string(255)
video_has_no_spoken_words :boolean default(FALSE)
vision_analyzed_at :datetime
vision_model_used :string
youtube_caption_synced_at :datetime
youtube_chapters_draft :jsonb
youtube_chapters_generation_error :text
youtube_chapters_generation_status :string
youtube_description :string
youtube_privacy_status :string
youtube_synced_at :datetime
youtube_title :string
youtube_upload_date :datetime
youtube_upload_status :string
created_at :datetime not null
updated_at :datetime not null
assemblyai_transcript_id :string
asset_file_id :string
cloudinary_asset_id :string
creator_id :integer
legacy_wistia_id :string(255)
poster_image_id :integer
purge_cache_request_id :string
updater_id :integer
youtube_id :string
youtube_thumbnail_image_id :integer
Indexes
by_type_inactive_id (type,inactive,id)
index_digital_assets_on_asset_file_id (asset_file_id) UNIQUE
index_digital_assets_on_cloudflare_uid (cloudflare_uid)
index_digital_assets_on_creator_id (creator_id)
index_digital_assets_on_inactive (inactive)
index_digital_assets_on_merged_from_ids (merged_from_ids) USING gin
index_digital_assets_on_poster_image_id (poster_image_id)
index_digital_assets_on_poster_offset (poster_offset)
index_digital_assets_on_slug (slug)
index_digital_assets_on_source (source)
index_digital_assets_on_transcription_state (transcription_state)
index_digital_assets_on_translations (translations) USING gin
index_digital_assets_on_type_and_slug (type,slug) UNIQUE
index_digital_assets_on_updater_id (updater_id)
index_digital_assets_on_url (url)
index_digital_assets_on_vision_analyzed_at (vision_analyzed_at)
index_digital_assets_on_youtube_thumbnail_image_id (youtube_thumbnail_image_id)
index_images_on_fingerprint (fingerprint) WHERE (((type)::text = 'Image'::text) AND (fingerprint IS NOT NULL))
type_category (type,category)
type_entity_id (type,legacy_wistia_id)
type_title (type,title)
Foreign Keys
fk_rails_... (creator_id => parties.id)
fk_rails_... (poster_image_id => digital_assets.id) ON DELETE => nullify
fk_rails_... (updater_id => parties.id)
fk_rails_... (youtube_thumbnail_image_id => digital_assets.id) ON DELETE => nullify
Constant Summary collapse
- PRIVATE_CATEGORIES =
%w[ugc unlisted].freeze
- PUBLIC_CATEGORIES =
%w[installation news product testimonial training webinar].freeze
- CATEGORIES =
PRIVATE_CATEGORIES + PUBLIC_CATEGORIES
- POPULAR_TAGS =
Override popular tags for videos to exclude legacy 'no-index'
(DigitalAsset::POPULAR_TAGS - ['no-index']).freeze
- POPULAR_TAGS_FORM_HINT =
'for-product-page: product page video carousel. for-support-page: support section.'.freeze
Constants included from Models::Embeddable
Models::Embeddable::DEFAULT_MODEL, Models::Embeddable::MAX_CONTENT_LENGTH
Constants inherited from DigitalAsset
Constants included from Models::Auditable
Models::Auditable::ALWAYS_IGNORED
Instance Attribute Summary collapse
- #category ⇒ Object readonly
Attributes included from Models::Translatable
#do_not_compact_translation_container
Attributes inherited from DigitalAsset
#force_new_slug, #refresh_cache, #title, #url
Has many collapse
- #reviews_io_videos ⇒ ActiveRecord::Relation<ReviewsIoVideo>
- #reviews_ios ⇒ ActiveRecord::Relation<ReviewsIo>
- #uploads ⇒ ActiveRecord::Relation<Upload>
-
#video_embeddings ⇒ ActiveRecord::Relation<ContentEmbedding::VideoEmbedding>
Association to the partitioned embeddings table for AI search.
Methods included from Models::CrossLinkable
#inbound_content_links, #outbound_content_links
Methods included from Models::Embeddable
Methods inherited from DigitalAsset
#digital_asset_product_lines, #generated_images, #product_lines, #site_maps
Methods included from Models::Taggable
Belongs to collapse
- #poster_image ⇒ Image
-
#youtube_thumbnail_image ⇒ Image
Optional Image library asset used only for YouTube custom thumbnail push (overrides poster / Dragonfly / CF still).
Methods included from Models::Auditable
Class Method Summary collapse
-
.ai_search ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are ai search.
-
.ai_search_warning? ⇒ Boolean
Check if the last AI search had issues (call from controller after running scope).
-
.all_tags(exclude_tags: []) ⇒ Object
Exclude legacy 'no-index' from tag selector lists for videos.
-
.by_max_cloudflare_height ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are by max cloudflare height.
-
.by_min_cloudflare_height ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are by min cloudflare height.
-
.categories_for_select ⇒ Object
Grouped list of categories for selects Returns a hash suitable for grouped_options_for_select, e.g.
-
.clear_ai_search_warning! ⇒ Object
Clear the AI search warning.
-
.cloudflare_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are cloudflare videos.
-
.embeddable_content_types ⇒ Object
Embeddable configuration - single unified embedding like Image.
-
.expanded_description_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are expanded description presence.
-
.generate_openai_query_embedding(query, model: 'text-embedding-3-small') ⇒ Array<Float>?
Generate query embedding using OpenAI text-embedding-3-small This matches the model used for stored video embeddings.
-
.hosted_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are hosted videos.
-
.hosting_type ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are hosting type.
-
.hybrid_search ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are hybrid search.
-
.linkable_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are linkable videos.
-
.mark_videos_as_no_spoken_words(video_ids) ⇒ Object
Mark videos as having no spoken words in batch.
-
.meta_description_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are meta description presence.
-
.meta_title_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are meta title presence.
-
.public_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are public videos.
- .purge_edge_cache(index_only: true) ⇒ Object
- .ransackable_scopes(_auth_object = nil) ⇒ Object
-
.sub_header_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are sub header presence.
-
.transcript_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are transcript presence.
-
.videos_likely_no_spoken_words ⇒ Object
Find videos that likely have no spoken words (have transcript ID but no transcript data).
-
.videos_with_assemblyai_transcripts ⇒ Object
Class methods for AssemblyAI transcript management.
-
.youtube_id_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are youtube id presence.
Instance Method Summary collapse
-
#audio_download_ready? ⇒ Boolean
Check if audio download is ready for transcription.
- #audio_extraction_upload ⇒ Object
-
#available_translated_vtt_locales ⇒ Array<String>
Get all available translated VTT locales.
- #can_retrieve_existing_transcript? ⇒ Boolean
-
#cf_animated_thumbnail_url(duration: 5, height: 480, fps: 8) ⇒ String?
Animated GIF thumbnail from Cloudflare Stream (first N seconds of video) Used for email embeds where tags are not supported.
- #cf_poster_url ⇒ Object
-
#cloudflare_all_captions ⇒ Object
Get all captions currently on Cloudflare.
-
#cloudflare_audio_download_url ⇒ Object
Get audio download URL from Cloudflare data.
- #cloudflare_captions_status ⇒ Object
-
#cloudflare_default_download_url ⇒ Object
Get default video download URL from Cloudflare data.
-
#cloudflare_download_status(download_type) ⇒ Object
Get download status for a specific type.
-
#cloudflare_duration ⇒ Object
Get Cloudflare video duration from cached data.
-
#cloudflare_file_size ⇒ Object
Get Cloudflare video file size from cached data.
-
#cloudflare_input_dimensions ⇒ Object
Get Cloudflare video input dimensions from cached data.
-
#cloudflare_mp4_url ⇒ Object
MP4 URL for SEO and fallback purposes.
-
#cloudflare_processing_percentage ⇒ Object
Get Cloudflare video processing percentage from cached data.
-
#cloudflare_ready_to_stream? ⇒ Boolean
Check if video is ready to stream from cached data.
-
#cloudflare_status ⇒ Object
Get Cloudflare video status information from cached data.
- #cloudflare_video_url ⇒ Object
- #cms_url ⇒ Object
-
#content_for_embedding(_content_type = :primary) ⇒ Object
Returns unified content for embedding - combines metadata + transcript Similar to how Image combines visual analysis + metadata + tags.
- #create_audio_extraction_upload(audio_file_path) ⇒ Object
-
#delete_from_cloudflare ⇒ Object
Delete video from Cloudflare Stream.
-
#delete_mp4_download(download_type) ⇒ Object
Delete an MP4 download from Cloudflare Stream.
- #enable_mp4 ⇒ Object
-
#enable_mp4_download(download_type) ⇒ Object
Enable specific type of video download (default or audio).
-
#enqueue_cloudflare_sync ⇒ Object
Enqueue Cloudflare synchronization after video creation.
-
#enqueue_cloudflare_sync_if_newly_cloudflare ⇒ Object
Enqueue Cloudflare synchronization when a video is updated to use Cloudflare.
-
#ensure_mp4_downloads_enabled ⇒ Object
Check if video downloads are enabled and enable them if not (legacy method - enables default).
-
#extract_poster_at_timestamp(timestamp_seconds = 5.0) ⇒ Object
Extract a poster frame from the video at the specified timestamp.
-
#fetch_cloudflare_captions ⇒ Object
Fetch captions from Cloudflare API (used by refresh_cloudflare_data).
- #fetch_cloudflare_duration ⇒ Object
-
#fetch_duration_in_seconds ⇒ Object
Probe the video for its duration.
- #ffmpeg_location ⇒ Object
- #ffprobe_location ⇒ Object
-
#frames_count ⇒ Object
Count the number of frames in the video.
-
#gallery_square_poster_url(width:, height:) ⇒ Object
Square still for PDP gallery (ImageKit pad_resize when poster_image exists; else Cloudflare Stream clip in box).
-
#has_assemblyai_transcript_id? ⇒ Boolean
AssemblyAI transcript ID methods.
-
#has_audio_download? ⇒ Boolean
Check if audio download is enabled.
-
#has_audio_extraction? ⇒ Boolean
Convenience methods for utility views (transcription_options, cloudflare_updates).
-
#has_cloudflare_caption_for_locale?(locale) ⇒ Boolean
Check if a specific language caption exists on Cloudflare.
- #has_cloudflare_captions? ⇒ Boolean
-
#has_default_download? ⇒ Boolean
Check if default video download is enabled.
-
#has_no_spoken_words? ⇒ Boolean
Check if video has been marked as having no spoken words.
- #has_polished_vtt? ⇒ Boolean
- #has_seo_data? ⇒ Boolean
- #has_structured_transcript_json? ⇒ Boolean
-
#has_translated_vtt?(locale) ⇒ Boolean
Check if translated VTT captions are available for a specific locale.
- #indexed_video? ⇒ Boolean
- #is_cloudflare? ⇒ Boolean
- #is_hosted? ⇒ Boolean
-
#mark_as_no_spoken_words! ⇒ Object
Mark video as having no spoken words (useful for videos with only music, sound effects, etc.).
-
#missing_cloudflare_captions ⇒ Object
Get list of captions available locally but not on Cloudflare.
- #original_transcript_data ⇒ Object
-
#refresh_cloudflare_data ⇒ Object
Refresh and cache Cloudflare video data including downloads information and captions.
- #refresh_duration ⇒ Object
- #related_sources ⇒ Object
- #sentences_data ⇒ Object
- #set_duration_in_seconds ⇒ Object
- #set_poster_from_attachment ⇒ Object
- #should_skip_transcription? ⇒ Boolean
- #streamio_movie ⇒ Object
- #structured_transcript_metadata ⇒ Object
-
#structured_transcript_paragraphs ⇒ Object
Structured transcript JSON methods.
- #structured_transcript_vtt_stats ⇒ Object
- #thumbnail_url(size: '300x300#c', background: '#ffffff', width: nil, height: nil, thumbnail: true) ⇒ Object
- #to_s ⇒ Object
-
#transcript_word_count ⇒ Object
Transcript methods.
-
#translations ⇒ Object
Override translations accessor to ensure it's never nil.
- #translations=(value) ⇒ Object
-
#unmark_as_no_spoken_words! ⇒ Object
Unmark video as having no spoken words (if it was incorrectly marked).
- #update_transcript_data(result) ⇒ Object
- #vtt_original_data ⇒ Object
- #vtt_original_text_for_display ⇒ Object
- #vtt_polished_data ⇒ Object
- #vtt_polished_text_for_display ⇒ Object
-
#vtt_translated_data(locale) ⇒ Array<Hash>?
Get translated VTT data for a specific locale.
-
#vtt_translated_text_for_display(locale) ⇒ Array<Hash>
Get translated VTT data formatted for display/download.
- #website_visible? ⇒ Boolean
- #youtube_chapters_draft ⇒ Object
- #youtube_chapters_generation_error ⇒ Object
- #youtube_chapters_generation_in_progress? ⇒ Boolean
-
#youtube_chapters_generation_status ⇒ Object
YouTube chapter draft columns (see migration).
-
#youtube_local_caption_tracks? ⇒ Boolean
True if we have timed VTT data locally that YouTube::CaptionService can upload (English and/or translations).
Methods included from Models::CrossLinkable
#content_links_count, #linked_content, #linked_posts, #linked_publications, #linked_showcases, #linked_videos
Methods included from Models::HybridSearchable
Methods included from Models::Embeddable
#embeddable_locales, #embedding_content_hash, embedding_partition_class, #embedding_stale?, #embedding_type_name, #embedding_vector, #find_content_embedding, #find_similar, #generate_all_embeddings!, #generate_chunked_embeddings!, #generate_embedding!, #has_embedding?, #locale_for_embedding, #needs_chunking?, regenerate_all_embeddings, semantic_search
Methods included from Models::Translatable
#compact_translation_container, skip_compaction_for
Methods inherited from DigitalAsset
active, #alerts, all_locales, #all_my_items, #asset_identifier, available_banner_tags, available_og_image_tags, available_page_tags, available_page_tags_with_paths, banner_tag_for, by_category, by_item_ids, by_item_skus, by_party_ids, by_product_category_id_direct, by_product_category_id_direct_or_optional, by_product_line_id, by_product_line_id_direct, by_product_line_path, categorized, #cross_links_opportunities_to_parties, #dimensions, exclude_tags, #file_basename, images, #is_image?, #is_video?, #items, localized_for, localized_for_or_not, not_by_party_ids, not_by_product_line_id, not_by_product_line_path, og_image_tag_for, #opportunities, page_tag_for, #parties, #product_categories, #product_lines_display, #product_lines_for_sorting, #purge_edge_cache, related_to_item_id, #reviews, #sanitize_urls, #seo_title, #should_generate_new_friendly_id?, #should_sanitize_urls?, show_hidden_tags, #slug_candidates, tag_presence, tagged_with_all, #tags_display, #touch_related, videos, with_product_line_urls, without_product_categories, without_product_lines
Methods included from Models::Taggable
#add_tag, #has_tag?, normalize_tag_names, not_tagged_with, #remove_tag, #tag_list, #tag_list=, #taggable_type_for_tagging, tagged_with, #tags, #tags=, tags_cloud, tags_exclude, tags_include, with_all_tags, with_any_tags, without_all_tags, without_any_tags
Methods included from Models::ItemScopable
by_product_category_id, by_product_category_id_direct, by_product_category_path, by_product_category_path_exact, by_product_category_url, by_product_category_url_exact, by_product_line_id, by_product_line_path, by_product_line_path_full, by_product_line_url, by_product_line_url_full, not_by_product_category_id, not_by_product_line_id
Methods included from Models::Auditable
#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record
Methods inherited from ApplicationRecord
ransackable_associations, ransackable_attributes, ransortable_attributes, #to_relation
Methods included from Models::EventPublishable
Instance Attribute Details
#category ⇒ Object (readonly)
236 |
# File 'app/models/video.rb', line 236 validates :category, presence: true |
Class Method Details
.ai_search ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are ai search. Active Record Scope
305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 |
# File 'app/models/video.rb', line 305 scope :ai_search, ->(query, limit: 500, max_distance: nil) { # Clear any previous warning self.last_ai_search_warning = nil return none if query.blank? # Generate query embedding using OpenAI (same model as stored video embeddings) = (query) unless self.last_ai_search_warning = 'Could not generate AI embedding for your query. Please try a different search term.' return none end # Format vector for SQL with explicit dimension cast dimensions = .size vector_literal = "[#{.join(',')}]" # Join to the partitioned embeddings table for Videos # Videos use unified 'primary' content type (metadata + transcript combined) base_query = joins(:video_embeddings) .where(content_embeddings_videos: { content_type: 'primary' }) .where.not(content_embeddings_videos: { embedding: nil }) .select( "#{table_name}.*", Arel.sql(sanitize_sql_array([ "content_embeddings_videos.embedding::vector(#{dimensions}) <=> ?::vector(#{dimensions}) AS neighbor_distance", vector_literal ])) ) .order(Arel.sql(sanitize_sql_array([ "content_embeddings_videos.embedding::vector(#{dimensions}) <=> ?::vector(#{dimensions}) ASC", vector_literal ]))) # Optionally filter by distance threshold if max_distance.present? base_query = base_query.where( Arel.sql(sanitize_sql_array([ "content_embeddings_videos.embedding::vector(#{dimensions}) <=> ?::vector(#{dimensions}) < ?", vector_literal, max_distance ])) ) end base_query.limit(limit) } |
.ai_search_warning? ⇒ Boolean
Check if the last AI search had issues (call from controller after running scope)
392 393 394 |
# File 'app/models/video.rb', line 392 def self.ai_search_warning? last_ai_search_warning.present? end |
.all_tags(exclude_tags: []) ⇒ Object
Exclude legacy 'no-index' from tag selector lists for videos
443 444 445 |
# File 'app/models/video.rb', line 443 def self.(exclude_tags: []) super(exclude_tags: + ['no-index']) end |
.by_max_cloudflare_height ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are by max cloudflare height. Active Record Scope
280 281 282 283 284 |
# File 'app/models/video.rb', line 280 scope :by_max_cloudflare_height, lambda { |height| return all if height.blank? where("(cloudflare_data -> 'input' ->> 'height')::integer <= ?", height.to_i) } |
.by_min_cloudflare_height ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are by min cloudflare height. Active Record Scope
274 275 276 277 278 |
# File 'app/models/video.rb', line 274 scope :by_min_cloudflare_height, lambda { |height| return all if height.blank? where("(cloudflare_data -> 'input' ->> 'height')::integer >= ?", height.to_i) } |
.categories_for_select ⇒ Object
Grouped list of categories for selects
Returns a hash suitable for grouped_options_for_select, e.g.
{ 'Public' => [["product", "product"], ...], 'Private' => [["unlisted", "unlisted"], ...] }
435 436 437 438 439 440 |
# File 'app/models/video.rb', line 435 def self.categories_for_select { 'Public' => PUBLIC_CATEGORIES.map { |category| [category.titleize, category] }, 'Private' => PRIVATE_CATEGORIES.map { |category| [category.titleize, category] } } end |
.clear_ai_search_warning! ⇒ Object
Clear the AI search warning
397 398 399 |
# File 'app/models/video.rb', line 397 def self.clear_ai_search_warning! self.last_ai_search_warning = nil end |
.cloudflare_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are cloudflare videos. Active Record Scope
251 |
# File 'app/models/video.rb', line 251 scope :cloudflare_videos, -> { where.not(cloudflare_uid: [nil, '']) } |
.embeddable_content_types ⇒ Object
Embeddable configuration - single unified embedding like Image
1197 1198 1199 |
# File 'app/models/video.rb', line 1197 def self. [:primary] end |
.expanded_description_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are expanded description presence. Active Record Scope
269 |
# File 'app/models/video.rb', line 269 scope :expanded_description_presence, ->(param) { param.to_b ? where.not(expanded_description: [nil, '']) : where(expanded_description: [nil, '']) } |
.generate_openai_query_embedding(query, model: 'text-embedding-3-small') ⇒ Array<Float>?
Generate query embedding using OpenAI text-embedding-3-small
This matches the model used for stored video embeddings
359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 |
# File 'app/models/video.rb', line 359 def self.(query, model: 'text-embedding-3-small') return nil if query.blank? cache_key = "query_embedding:openai:#{model}:#{Digest::SHA256.hexdigest(query.downcase.strip)[0..15]}" # Try cache first cached = Rails.cache.read(cache_key) return cached if cached.present? # Use RubyLLM to generate embedding with OpenAI result = RubyLLM.(query, model: model, provider: :openai, assume_model_exists: true) vector = result&.vectors # Cache for 24 hours Rails.cache.write(cache_key, vector, expires_in: 24.hours) if vector.present? vector rescue RubyLLM::RateLimitError => e Rails.logger.warn "Rate limited generating video query embedding: #{e.}" nil rescue RubyLLM::Error => e Rails.logger.error "RubyLLM error generating video query embedding: #{e.}" nil rescue StandardError => e Rails.logger.error "Failed to generate OpenAI query embedding: #{e.}" ErrorReporting.warning(e, context: { query: query.truncate(100), model: model }, reason: 'embedding_generation_failed') nil end |
.hosted_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are hosted videos. Active Record Scope
249 |
# File 'app/models/video.rb', line 249 scope :hosted_videos, -> { where.not(attachment_uid: nil) } |
.hosting_type ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are hosting type. Active Record Scope
252 253 254 255 256 257 258 259 260 261 262 |
# File 'app/models/video.rb', line 252 scope :hosting_type, ->(hosting_type) { case hosting_type&.to_sym when :cloudflare cloudflare_videos when :hosted hosted_videos else all end } |
.hybrid_search ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are hybrid search. Active Record Scope
412 413 414 415 416 417 418 419 420 421 422 423 |
# File 'app/models/video.rb', line 412 scope :hybrid_search, ->(query, limit: 500) { return none if query.blank? ai_ids = ai_search(query, limit: limit).pluck(:id) keyword_ids = begin keyword_search(query).limit(limit).pluck(:id) rescue PgSearch::EmptyQueryError [] end rrf_ranked_relation(ai_ids, keyword_ids, limit: limit) } |
.linkable_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are linkable videos. Active Record Scope
248 |
# File 'app/models/video.rb', line 248 scope :linkable_videos, -> { active.where(category: CATEGORIES).where.not(slug: nil).cloudflare_videos } |
.mark_videos_as_no_spoken_words(video_ids) ⇒ Object
Mark videos as having no spoken words in batch
846 847 848 |
# File 'app/models/video.rb', line 846 def self.mark_videos_as_no_spoken_words(video_ids) where(id: video_ids).update_all(video_has_no_spoken_words: true) end |
.meta_description_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are meta description presence. Active Record Scope
267 |
# File 'app/models/video.rb', line 267 scope :meta_description_presence, ->(param) { param.to_b ? where.not(meta_description: [nil, '']) : where(meta_description: [nil, '']) } |
.meta_title_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are meta title presence. Active Record Scope
266 |
# File 'app/models/video.rb', line 266 scope :meta_title_presence, ->(param) { param.to_b ? where.not(meta_title: [nil, '']) : where(meta_title: [nil, '']) } |
.public_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are public videos. Active Record Scope
247 |
# File 'app/models/video.rb', line 247 scope :public_videos, -> { active.where(category: PUBLIC_CATEGORIES).where.not(slug: nil).cloudflare_videos } |
.purge_edge_cache(index_only: true) ⇒ Object
401 402 403 404 405 406 407 408 409 |
# File 'app/models/video.rb', line 401 def self.purge_edge_cache(index_only: true) site_maps = SiteMap.where(category: 'video') urls = if index_only site_maps.where("path LIKE '%/video-media'").map(&:url) else site_maps.map(&:url) end EdgeCacheWorker.perform_async('urls' => urls) if urls.present? end |
.ransackable_scopes(_auth_object = nil) ⇒ Object
425 426 427 428 429 430 |
# File 'app/models/video.rb', line 425 def self.ransackable_scopes(_auth_object = nil) %i[title_search hosting_type transcript_presence meta_title_presence meta_description_presence sub_header_presence expanded_description_presence youtube_id_presence keyword_search by_product_line_id by_product_line_path by_product_category_id_direct tag_presence by_party_ids by_item_ids show_hidden_tags tags_include exclude_tags ai_search hybrid_search not_by_product_line_id not_by_party_ids by_min_cloudflare_height by_max_cloudflare_height] end |
.sub_header_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are sub header presence. Active Record Scope
268 |
# File 'app/models/video.rb', line 268 scope :sub_header_presence, ->(param) { param.to_b ? where.not(sub_header: [nil, '']) : where(sub_header: [nil, '']) } |
.transcript_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are transcript presence. Active Record Scope
265 |
# File 'app/models/video.rb', line 265 scope :transcript_presence, ->(param) { param.to_b ? where.not(transcript: [nil, '']) : where(transcript: [nil, '']) } |
.videos_likely_no_spoken_words ⇒ Object
Find videos that likely have no spoken words (have transcript ID but no transcript data)
839 840 841 842 843 |
# File 'app/models/video.rb', line 839 def self.videos_likely_no_spoken_words where.not(assemblyai_transcript_id: nil) .where(transcript: [nil, '']) .where(structured_transcript_json: [nil, {}]) end |
.videos_with_assemblyai_transcripts ⇒ Object
Class methods for AssemblyAI transcript management
834 835 836 |
# File 'app/models/video.rb', line 834 def self.videos_with_assemblyai_transcripts where.not(assemblyai_transcript_id: nil) end |
.youtube_id_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are youtube id presence. Active Record Scope
270 |
# File 'app/models/video.rb', line 270 scope :youtube_id_presence, ->(param) { param.to_b ? where.not(youtube_id: [nil, '']) : where(youtube_id: [nil, '']) } |
Instance Method Details
#audio_download_ready? ⇒ Boolean
Check if audio download is ready for transcription
535 536 537 538 539 |
# File 'app/models/video.rb', line 535 def audio_download_ready? return false unless has_audio_download? cloudflare_download_status('audio') == 'ready' end |
#audio_extraction_upload ⇒ Object
762 763 764 |
# File 'app/models/video.rb', line 762 def audio_extraction_upload uploads.in_category('audio_extraction').first end |
#available_translated_vtt_locales ⇒ Array<String>
Get all available translated VTT locales
1092 1093 1094 1095 1096 1097 1098 |
# File 'app/models/video.rb', line 1092 def available_translated_vtt_locales return [] unless structured_transcript_json.present? VideoProcessing::VideoTranslationService::SUPPORTED_LOCALES.keys.select do |locale| has_translated_vtt?(locale) end end |
#can_retrieve_existing_transcript? ⇒ Boolean
801 802 803 |
# File 'app/models/video.rb', line 801 def can_retrieve_existing_transcript? has_assemblyai_transcript_id? && !transcript.present? end |
#cf_animated_thumbnail_url(duration: 5, height: 480, fps: 8) ⇒ String?
Animated GIF thumbnail from Cloudflare Stream (first N seconds of video)
Used for email embeds where tags are not supported.
Cloudflare generates the GIF on the fly from the stored video.
474 475 476 477 478 |
# File 'app/models/video.rb', line 474 def cf_animated_thumbnail_url(duration: 5, height: 480, fps: 8) return nil unless is_cloudflare? "#{CF_STREAM_URL}/#{cloudflare_uid}/thumbnails/thumbnail.gif?time=0s&duration=#{duration}s&height=#{height}&fps=#{fps}" end |
#cf_poster_url ⇒ Object
462 463 464 |
# File 'app/models/video.rb', line 462 def cf_poster_url "#{CF_STREAM_URL}/#{cloudflare_uid}/thumbnails/thumbnail.jpg" end |
#cloudflare_all_captions ⇒ Object
Get all captions currently on Cloudflare
861 862 863 864 865 866 867 868 869 870 871 872 873 |
# File 'app/models/video.rb', line 861 def cloudflare_all_captions return [] unless is_cloudflare? # Use cached captions from cloudflare_data if available # This avoids real-time API calls on every page load if cloudflare_data.present? && cloudflare_data['captions'].present? cloudflare_data['captions'] else # Fallback to empty array if not cached # User can click "Refresh Cloudflare Data" to fetch latest [] end end |
#cloudflare_audio_download_url ⇒ Object
Get audio download URL from Cloudflare data
549 550 551 552 553 |
# File 'app/models/video.rb', line 549 def cloudflare_audio_download_url return nil unless has_audio_download? cloudflare_data.dig('downloads', 'audio', 'url') end |
#cloudflare_captions_status ⇒ Object
850 851 852 853 854 855 856 857 858 |
# File 'app/models/video.rb', line 850 def cloudflare_captions_status return nil unless is_cloudflare? captions = CloudflareStreamApi.instance.list_captions(cloudflare_uid) captions.find { |caption| caption['language'] == 'en' } rescue StandardError => e Rails.logger.error "Failed to check Cloudflare captions status for video #{id}: #{e.}" nil end |
#cloudflare_default_download_url ⇒ Object
Get default video download URL from Cloudflare data
542 543 544 545 546 |
# File 'app/models/video.rb', line 542 def cloudflare_default_download_url return nil unless has_default_download? cloudflare_data.dig('downloads', 'default', 'url') end |
#cloudflare_download_status(download_type) ⇒ Object
Get download status for a specific type
556 557 558 559 560 |
# File 'app/models/video.rb', line 556 def cloudflare_download_status(download_type) return nil unless is_cloudflare? cloudflare_data.dig('downloads', download_type, 'status') end |
#cloudflare_duration ⇒ Object
Get Cloudflare video duration from cached data
1042 1043 1044 1045 1046 |
# File 'app/models/video.rb', line 1042 def cloudflare_duration return nil unless is_cloudflare? cloudflare_data['duration'] end |
#cloudflare_file_size ⇒ Object
Get Cloudflare video file size from cached data
1022 1023 1024 1025 1026 |
# File 'app/models/video.rb', line 1022 def cloudflare_file_size return nil unless is_cloudflare? cloudflare_data['size'] end |
#cloudflare_input_dimensions ⇒ Object
Get Cloudflare video input dimensions from cached data
1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 |
# File 'app/models/video.rb', line 1029 def cloudflare_input_dimensions return nil unless is_cloudflare? input = cloudflare_data['input'] return nil unless input { width: input['width'], height: input['height'] } end |
#cloudflare_mp4_url ⇒ Object
MP4 URL for SEO and fallback purposes
488 489 490 491 492 |
# File 'app/models/video.rb', line 488 def cloudflare_mp4_url return nil unless is_cloudflare? "#{CF_STREAM_URL}/#{cloudflare_uid}/downloads/default.mp4" end |
#cloudflare_processing_percentage ⇒ Object
Get Cloudflare video processing percentage from cached data
1012 1013 1014 1015 1016 1017 1018 1019 |
# File 'app/models/video.rb', line 1012 def cloudflare_processing_percentage return nil unless is_cloudflare? status = cloudflare_status return nil unless status status['pctComplete'] end |
#cloudflare_ready_to_stream? ⇒ Boolean
Check if video is ready to stream from cached data
1005 1006 1007 1008 1009 |
# File 'app/models/video.rb', line 1005 def cloudflare_ready_to_stream? return false unless is_cloudflare? cloudflare_data['readyToStream'] == true end |
#cloudflare_status ⇒ Object
Get Cloudflare video status information from cached data
998 999 1000 1001 1002 |
# File 'app/models/video.rb', line 998 def cloudflare_status return nil unless is_cloudflare? cloudflare_data['status'] end |
#cloudflare_video_url ⇒ Object
480 481 482 483 484 485 |
# File 'app/models/video.rb', line 480 def cloudflare_video_url return nil unless is_cloudflare? # Use HLS manifest for adaptive streaming with higher bandwidth hint for maximum quality "#{CF_STREAM_URL}/#{cloudflare_uid}/manifest/video.m3u8?clientBandwidthHint=10.0" end |
#cms_url ⇒ Object
616 617 618 |
# File 'app/models/video.rb', line 616 def cms_url @cms_url ||= Www::Router.new.video_media_url(self) end |
#content_for_embedding(_content_type = :primary) ⇒ Object
Returns unified content for embedding - combines metadata + transcript
Similar to how Image combines visual analysis + metadata + tags
1203 1204 1205 1206 1207 1208 1209 1210 |
# File 'app/models/video.rb', line 1203 def (_content_type = :primary) [ , , , ].flatten.compact.join("\n\n") end |
#create_audio_extraction_upload(audio_file_path) ⇒ Object
783 784 785 786 787 788 789 790 791 792 793 794 |
# File 'app/models/video.rb', line 783 def create_audio_extraction_upload(audio_file_path) # Remove existing audio extraction upload if it exists audio_extraction_upload&.destroy # Create new upload using uploadify Upload.uploadify( audio_file_path, 'audio_extraction', self, "#{title.parameterize}-audio.mp3" ) end |
#delete_from_cloudflare ⇒ Object
Delete video from Cloudflare Stream
981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 |
# File 'app/models/video.rb', line 981 def delete_from_cloudflare return false unless is_cloudflare? Rails.logger.info "Deleting video #{id} from Cloudflare Stream (UID: #{cloudflare_uid})" success = CloudflareStreamApi.instance.delete_video(cloudflare_uid) if success Rails.logger.info "Successfully deleted video #{id} from Cloudflare Stream" else Rails.logger.error "Failed to delete video #{id} from Cloudflare Stream" end success end |
#delete_mp4_download(download_type) ⇒ Object
Delete an MP4 download from Cloudflare Stream
504 505 506 507 508 |
# File 'app/models/video.rb', line 504 def delete_mp4_download(download_type) return false unless is_cloudflare? CloudflareStreamApi.instance.delete_mp4_download(cloudflare_uid, download_type) end |
#enable_mp4 ⇒ Object
494 495 496 |
# File 'app/models/video.rb', line 494 def enable_mp4 CloudflareStreamApi.instance.enable_mp4_downloads(cloudflare_uid) end |
#enable_mp4_download(download_type) ⇒ Object
Enable specific type of video download (default or audio)
499 500 501 |
# File 'app/models/video.rb', line 499 def enable_mp4_download(download_type) CloudflareStreamApi.instance.enable_mp4_download(cloudflare_uid, download_type) end |
#enqueue_cloudflare_sync ⇒ Object
Enqueue Cloudflare synchronization after video creation
948 949 950 951 952 953 954 955 |
# File 'app/models/video.rb', line 948 def enqueue_cloudflare_sync return unless is_cloudflare? Rails.logger.info "Enqueuing Cloudflare monitoring for newly created video #{id}" # Enqueue the monitoring worker to wait for processing and create downloads CloudflareVideoMonitorWorker.perform_async(id) end |
#enqueue_cloudflare_sync_if_newly_cloudflare ⇒ Object
Enqueue Cloudflare synchronization when a video is updated to use Cloudflare
958 959 960 961 962 963 964 965 |
# File 'app/models/video.rb', line 958 def enqueue_cloudflare_sync_if_newly_cloudflare return unless cloudflare_uid_previously_changed? && cloudflare_uid.present? Rails.logger.info "Enqueuing Cloudflare monitoring for video #{id} that was updated to use Cloudflare" # Enqueue the monitoring worker to wait for processing and create downloads CloudflareVideoMonitorWorker.perform_async(id) end |
#ensure_mp4_downloads_enabled ⇒ Object
Check if video downloads are enabled and enable them if not (legacy method - enables default)
511 512 513 514 515 516 517 518 |
# File 'app/models/video.rb', line 511 def ensure_mp4_downloads_enabled return unless is_cloudflare? return if has_default_download? Rails.logger.info "Enabling video downloads for video #{cloudflare_uid}" enable_mp4 end |
#extract_poster_at_timestamp(timestamp_seconds = 5.0) ⇒ Object
Extract a poster frame from the video at the specified timestamp
968 969 970 971 972 973 974 975 976 977 978 |
# File 'app/models/video.rb', line 968 def ( = 5.0) return false unless is_cloudflare? || is_hosted? Rails.logger.info "Extracting poster for video #{id} at #{} seconds" service = VideoPosterExtractionService.new(self, ) service.extract_poster rescue VideoPosterExtractionService::ExtractionError => e Rails.logger.error "Failed to extract poster for video #{id}: #{e.}" false end |
#fetch_cloudflare_captions ⇒ Object
Fetch captions from Cloudflare API (used by refresh_cloudflare_data)
876 877 878 879 880 881 882 883 |
# File 'app/models/video.rb', line 876 def fetch_cloudflare_captions return [] unless is_cloudflare? CloudflareStreamApi.instance.list_captions(cloudflare_uid) rescue StandardError => e Rails.logger.error "Failed to fetch Cloudflare captions for video #{id}: #{e.}" [] end |
#fetch_cloudflare_duration ⇒ Object
636 637 638 |
# File 'app/models/video.rb', line 636 def fetch_cloudflare_duration cloudflare_duration end |
#fetch_duration_in_seconds ⇒ Object
Probe the video for its duration
625 626 627 628 629 630 631 632 633 634 |
# File 'app/models/video.rb', line 625 def fetch_duration_in_seconds d = if is_hosted? && &.path.present? cmd = "#{ffprobe_location} -i #{.path} -show_entries format=duration -v quiet -of csv=\"p=0\"" res = `#{cmd}`.strip BigDecimal(res) elsif is_cloudflare? && cloudflare_uid.present? fetch_cloudflare_duration end d&.round end |
#ffmpeg_location ⇒ Object
582 583 584 |
# File 'app/models/video.rb', line 582 def ffmpeg_location @ffmpeg_location ||= `which ffmpeg`.presence.strip || '/usr/bin/ffmpeg' end |
#ffprobe_location ⇒ Object
586 587 588 |
# File 'app/models/video.rb', line 586 def ffprobe_location @ffprobe_location ||= `which ffprobe`.presence.strip || '/usr/bin/ffprobe' end |
#frames_count ⇒ Object
Count the number of frames in the video
656 657 658 659 660 661 662 |
# File 'app/models/video.rb', line 656 def frames_count return unless is_hosted? cmd = "#{ffprobe_location} -i #{.path} -v error -select_streams v:0 -show_entries stream=nb_frames -of default=nokey=1:noprint_wrappers=1" res = `#{cmd}`.strip res.to_i end |
#gallery_square_poster_url(width:, height:) ⇒ Object
Square still for PDP gallery (ImageKit pad_resize when poster_image exists; else Cloudflare Stream clip in box)
574 575 576 577 578 579 580 |
# File 'app/models/video.rb', line 574 def gallery_square_poster_url(width:, height:) if poster_image.present? thumbnail_url(width: width, height: height, thumbnail: true) elsif is_cloudflare? "#{cf_poster_url}?width=#{width.to_i}&height=#{height.to_i}&fit=clip" end end |
#has_assemblyai_transcript_id? ⇒ Boolean
AssemblyAI transcript ID methods
797 798 799 |
# File 'app/models/video.rb', line 797 def has_assemblyai_transcript_id? assemblyai_transcript_id.present? end |
#has_audio_download? ⇒ Boolean
Check if audio download is enabled
528 529 530 531 532 |
# File 'app/models/video.rb', line 528 def has_audio_download? return false unless is_cloudflare? cloudflare_data.dig('downloads', 'audio').present? end |
#has_audio_extraction? ⇒ Boolean
Convenience methods for utility views (transcription_options, cloudflare_updates)
771 772 773 |
# File 'app/models/video.rb', line 771 def has_audio_extraction? audio_extraction_upload.present? end |
#has_cloudflare_caption_for_locale?(locale) ⇒ Boolean
Check if a specific language caption exists on Cloudflare
886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 |
# File 'app/models/video.rb', line 886 def has_cloudflare_caption_for_locale?(locale) return false unless is_cloudflare? # Map our locale codes to Cloudflare language codes cloudflare_lang = case locale.to_s when 'en', 'en-US', 'en-CA', 'en-GB' 'en' when 'fr', 'fr-FR', 'fr-CA' 'fr' when 'es', 'es-ES', 'es-MX' 'es' when 'pl', 'pl-PL' 'pl' else locale.to_s.split('-').first end cloudflare_all_captions.any? { |caption| caption['language'] == cloudflare_lang } end |
#has_cloudflare_captions? ⇒ Boolean
775 776 777 |
# File 'app/models/video.rb', line 775 def has_cloudflare_captions? cloudflare_captions_status.present? end |
#has_default_download? ⇒ Boolean
Check if default video download is enabled
521 522 523 524 525 |
# File 'app/models/video.rb', line 521 def has_default_download? return false unless is_cloudflare? cloudflare_data.dig('downloads', 'default').present? end |
#has_no_spoken_words? ⇒ Boolean
Check if video has been marked as having no spoken words
829 830 831 |
# File 'app/models/video.rb', line 829 def has_no_spoken_words? video_has_no_spoken_words? end |
#has_polished_vtt? ⇒ Boolean
1067 1068 1069 1070 1071 |
# File 'app/models/video.rb', line 1067 def has_polished_vtt? return false unless structured_transcript_json.present? structured_transcript_json['vtt_polished'].present? end |
#has_seo_data? ⇒ Boolean
766 767 768 |
# File 'app/models/video.rb', line 766 def has_seo_data? .present? && .present? && .present? end |
#has_structured_transcript_json? ⇒ Boolean
779 780 781 |
# File 'app/models/video.rb', line 779 def has_structured_transcript_json? structured_transcript_json.present? end |
#has_translated_vtt?(locale) ⇒ Boolean
Check if translated VTT captions are available for a specific locale
1083 1084 1085 1086 1087 1088 |
# File 'app/models/video.rb', line 1083 def has_translated_vtt?(locale) return false unless structured_transcript_json.present? vtt_key = "vtt_#{locale.to_s.underscore}" structured_transcript_json[vtt_key].present? end |
#indexed_video? ⇒ Boolean
1179 1180 1181 |
# File 'app/models/video.rb', line 1179 def indexed_video? !inactive? && PRIVATE_CATEGORIES.exclude?(category) end |
#is_cloudflare? ⇒ Boolean
447 448 449 |
# File 'app/models/video.rb', line 447 def is_cloudflare? cloudflare_uid.present? end |
#is_hosted? ⇒ Boolean
451 452 453 |
# File 'app/models/video.rb', line 451 def is_hosted? .present? end |
#mark_as_no_spoken_words! ⇒ Object
Mark video as having no spoken words (useful for videos with only music, sound effects, etc.)
819 820 821 |
# File 'app/models/video.rb', line 819 def mark_as_no_spoken_words! update!(video_has_no_spoken_words: true) end |
#missing_cloudflare_captions ⇒ Object
Get list of captions available locally but not on Cloudflare
907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 |
# File 'app/models/video.rb', line 907 def missing_cloudflare_captions return [] unless is_cloudflare? missing = [] # Check English missing << { locale: 'en', name: 'English' } if has_polished_vtt? && !has_cloudflare_caption_for_locale?('en') # Check translations VideoProcessing::VideoTranslationService::SUPPORTED_LOCALES.each do |locale, info| missing << { locale: locale, name: info[:name] } if has_translated_vtt?(locale) && !has_cloudflare_caption_for_locale?(locale) end missing end |
#original_transcript_data ⇒ Object
1173 1174 1175 1176 1177 |
# File 'app/models/video.rb', line 1173 def original_transcript_data return nil unless structured_transcript_json.present? structured_transcript_json['original_transcript'] end |
#poster_image ⇒ Image
224 |
# File 'app/models/video.rb', line 224 belongs_to :poster_image, class_name: 'Image', optional: true |
#refresh_cloudflare_data ⇒ Object
Refresh and cache Cloudflare video data including downloads information and captions
924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 |
# File 'app/models/video.rb', line 924 def refresh_cloudflare_data return false unless is_cloudflare? video_data = CloudflareStreamApi.instance.get_video(cloudflare_uid) if video_data # Fetch downloads information separately and merge it into cloudflare_data downloads_data = CloudflareStreamApi.instance.get_downloads(cloudflare_uid) # Merge downloads into the video data structure video_data['downloads'] = downloads_data if downloads_data # Fetch and cache captions list to avoid real-time API calls on page load captions_data = fetch_cloudflare_captions video_data['captions'] = captions_data if captions_data.present? update!(cloudflare_data: video_data) true else false end end |
#refresh_duration ⇒ Object
646 647 648 649 650 651 652 653 |
# File 'app/models/video.rb', line 646 def refresh_duration if set_duration_in_seconds save! true else false end end |
#related_sources ⇒ Object
1187 1188 1189 1190 1191 1192 1193 1194 |
# File 'app/models/video.rb', line 1187 def Source.where( 'name LIKE ? OR name LIKE ? OR description LIKE ?', title.present? ? "%#{title}%" : nil, youtube_title.present? ? "%#{youtube_title}%" : nil, youtube_id.present? ? "%#{youtube_id}%" : nil ) end |
#reviews_io_videos ⇒ ActiveRecord::Relation<ReviewsIoVideo>
222 |
# File 'app/models/video.rb', line 222 has_many :reviews_io_videos, dependent: :destroy |
#reviews_ios ⇒ ActiveRecord::Relation<ReviewsIo>
223 |
# File 'app/models/video.rb', line 223 has_many :reviews_ios, through: :reviews_io_videos |
#sentences_data ⇒ Object
1167 1168 1169 1170 1171 |
# File 'app/models/video.rb', line 1167 def sentences_data return nil unless structured_transcript_json.present? structured_transcript_json['sentences'] end |
#set_duration_in_seconds ⇒ Object
640 641 642 643 644 |
# File 'app/models/video.rb', line 640 def set_duration_in_seconds return unless (d = fetch_duration_in_seconds) self.duration_in_seconds = d end |
#set_poster_from_attachment ⇒ Object
590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 |
# File 'app/models/video.rb', line 590 def return if .blank? new_name = File.basename(.name, '.*') temp_path = Rails.root.join(Rails.application.config.x.temp_storage_path.to_s, "#{new_name}_#{Time.current.to_i}.jpg") = {} [:seek_time] = poster_offset.to_i / 1000 if poster_offset.present? streamio_movie.screenshot(temp_path.to_s, **) # Create a new Image record for the poster poster_image = Image.new( title: "#{title} - Poster", tags: ['video-poster'], source: 'video-extraction', new_image: File.open(temp_path), skip_notify: true ) if poster_image.save self.poster_image = poster_image Rails.logger.info "Created poster image #{poster_image.id} for video #{id}" else Rails.logger.error "Failed to create poster image for video #{id}: #{poster_image.errors.}" end end |
#should_skip_transcription? ⇒ Boolean
805 806 807 808 809 810 811 812 813 814 815 816 |
# File 'app/models/video.rb', line 805 def should_skip_transcription? # Skip if video has been marked as having no spoken words return true if video_has_no_spoken_words? # Skip if we already have a complete transcript return true if transcript.present? # Skip if we have a transcript ID but no transcript data (we'll try to retrieve it) return true if can_retrieve_existing_transcript? false end |
#streamio_movie ⇒ Object
455 456 457 458 459 460 |
# File 'app/models/video.rb', line 455 def streamio_movie return unless is_hosted? require 'streamio-ffmpeg' FFMPEG::Movie.new(.path) end |
#structured_transcript_metadata ⇒ Object
1056 1057 1058 1059 1060 |
# File 'app/models/video.rb', line 1056 def return {} unless structured_transcript_json.present? structured_transcript_json['metadata'] || {} end |
#structured_transcript_paragraphs ⇒ Object
Structured transcript JSON methods
1050 1051 1052 1053 1054 |
# File 'app/models/video.rb', line 1050 def structured_transcript_paragraphs return [] unless structured_transcript_json.present? structured_transcript_json['paragraphs'] || [] end |
#structured_transcript_vtt_stats ⇒ Object
1062 1063 1064 1065 |
# File 'app/models/video.rb', line 1062 def structured_transcript_vtt_stats # Legacy feature - return empty hash {} end |
#thumbnail_url(size: '300x300#c', background: '#ffffff', width: nil, height: nil, thumbnail: true) ⇒ Object
562 563 564 565 566 567 568 569 570 571 |
# File 'app/models/video.rb', line 562 def thumbnail_url(size: '300x300#c', background: '#ffffff', width: nil, height: nil, thumbnail: true) return cf_poster_url unless poster_image # Support both legacy size parameter and new width/height parameters if width.present? || height.present? poster_image.image_url(width: width, height: height, thumbnail: thumbnail, background: background) else poster_image.image_url(size: size, thumbnail: thumbnail, background: background) end end |
#to_s ⇒ Object
620 621 622 |
# File 'app/models/video.rb', line 620 def to_s "#{title} [#{id}]" end |
#transcript_word_count ⇒ Object
Transcript methods
665 666 667 668 669 |
# File 'app/models/video.rb', line 665 def transcript_word_count return 0 unless transcript.present? transcript.split(/\s+/).count end |
#translations ⇒ Object
Override translations accessor to ensure it's never nil
126 127 128 |
# File 'app/models/video.rb', line 126 def translations super || {} end |
#translations=(value) ⇒ Object
130 131 132 |
# File 'app/models/video.rb', line 130 def translations=(value) super(value || {}) end |
#unmark_as_no_spoken_words! ⇒ Object
Unmark video as having no spoken words (if it was incorrectly marked)
824 825 826 |
# File 'app/models/video.rb', line 824 def unmark_as_no_spoken_words! update!(video_has_no_spoken_words: false) end |
#update_transcript_data(result) ⇒ Object
671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 |
# File 'app/models/video.rb', line 671 def update_transcript_data(result) Rails.logger.info "Updating transcript data with: #{result.keys}" Rails.logger.info 'Current video state:' Rails.logger.info " - transcript present: #{transcript.present?}" Rails.logger.info " - meta_title present: #{.present?}" Rails.logger.info " - meta_description present: #{.present?}" Rails.logger.info " - expanded_description present: #{.present?}" Rails.logger.info " - duration_in_seconds: #{duration_in_seconds}" update_attributes = [] # Update transcript if provided if result[:transcript].present? Rails.logger.info "Will update transcript (length: #{result[:transcript].length})" update_attributes << :transcript end # Update AssemblyAI transcript ID if provided if result[:assemblyai_transcript_id].present? Rails.logger.info 'Will update AssemblyAI transcript ID' update_attributes << :assemblyai_transcript_id end # Update structured transcript JSON if provided (but only if not already processed) if result[:structured_transcript_json].present? && !has_structured_transcript_json? Rails.logger.info 'Will update structured transcript JSON' update_attributes << :structured_transcript_json elsif result[:structured_transcript_json].present? Rails.logger.info 'Skipping structured transcript JSON update - already processed' end # NOTE: VTT upload is handled separately and doesn't need to be tracked here # since it's stored as an Upload record # Update SEO content if provided and fields are blank if result[:seo_content].present? seo_content = result[:seo_content] if .blank? && seo_content['meta_title'].present? Rails.logger.info 'Will update meta_title' update_attributes << :meta_title end if .blank? && seo_content['meta_description'].present? Rails.logger.info 'Will update meta_description' update_attributes << :meta_description end if .blank? && seo_content['expanded_description'].present? Rails.logger.info 'Will update expanded_description' update_attributes << :expanded_description end else Rails.logger.warn 'No SEO content found in result' end # Set duration if blank if duration_in_seconds.blank? && result[:duration_in_seconds].present? Rails.logger.info 'Will update duration_in_seconds' update_attributes << :duration_in_seconds end Rails.logger.info "Final update attributes: #{update_attributes}" # Build the update hash update_hash = {} update_hash[:transcript] = result[:transcript] if result[:transcript].present? update_hash[:assemblyai_transcript_id] = result[:assemblyai_transcript_id] if result[:assemblyai_transcript_id].present? update_hash[:structured_transcript_json] = result[:structured_transcript_json] if result[:structured_transcript_json].present? && !has_structured_transcript_json? if result[:seo_content].present? seo_content = result[:seo_content] update_hash[:meta_title] = seo_content['meta_title'] if .blank? && seo_content['meta_title'].present? update_hash[:meta_description] = seo_content['meta_description'] if .blank? && seo_content['meta_description'].present? update_hash[:expanded_description] = seo_content['expanded_description'] if .blank? && seo_content['expanded_description'].present? end update_hash[:duration_in_seconds] = result[:duration_in_seconds] if duration_in_seconds.blank? && result[:duration_in_seconds].present? # Perform the update if update_hash.any? update!(update_hash) Rails.logger.info "Video updated successfully with: #{update_hash.keys}" else Rails.logger.info 'No updates needed' end end |
#uploads ⇒ ActiveRecord::Relation<Upload>
221 |
# File 'app/models/video.rb', line 221 has_many :uploads, as: :resource, dependent: :destroy |
#video_embeddings ⇒ ActiveRecord::Relation<ContentEmbedding::VideoEmbedding>
Association to the partitioned embeddings table for AI search
229 230 231 |
# File 'app/models/video.rb', line 229 has_many :video_embeddings, -> { where(embeddable_type: 'Video') }, class_name: 'ContentEmbedding::VideoEmbedding', foreign_key: :embeddable_id |
#vtt_original_data ⇒ Object
1125 1126 1127 1128 1129 |
# File 'app/models/video.rb', line 1125 def vtt_original_data return [] unless structured_transcript_json.present? structured_transcript_json['vtt_original'] || [] end |
#vtt_original_text_for_display ⇒ Object
1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 |
# File 'app/models/video.rb', line 1131 def vtt_original_text_for_display return [] unless structured_transcript_json.present? vtt_original_data.map do |caption| text = caption['text'] # Handle nested text structure if present text = text['text'] if text.is_a?(Hash) { 'start_time' => caption['start_time'], 'end_time' => caption['end_time'], 'text' => text } end end |
#vtt_polished_data ⇒ Object
1146 1147 1148 1149 1150 |
# File 'app/models/video.rb', line 1146 def vtt_polished_data return [] unless structured_transcript_json.present? structured_transcript_json['vtt_polished'] || [] end |
#vtt_polished_text_for_display ⇒ Object
1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 |
# File 'app/models/video.rb', line 1152 def vtt_polished_text_for_display return [] unless structured_transcript_json.present? vtt_polished_data.map do |caption| text = caption['text'] # Handle nested text structure from AI processing text = text['text'] if text.is_a?(Hash) { 'start_time' => caption['start_time'], 'end_time' => caption['end_time'], 'text' => text } end end |
#vtt_translated_data(locale) ⇒ Array<Hash>?
Get translated VTT data for a specific locale
1103 1104 1105 1106 1107 1108 |
# File 'app/models/video.rb', line 1103 def vtt_translated_data(locale) return nil unless structured_transcript_json.present? vtt_key = "vtt_#{locale.to_s.underscore}" structured_transcript_json[vtt_key] end |
#vtt_translated_text_for_display(locale) ⇒ Array<Hash>
Get translated VTT data formatted for display/download
1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 |
# File 'app/models/video.rb', line 1113 def vtt_translated_text_for_display(locale) return [] unless has_translated_vtt?(locale) vtt_translated_data(locale).map do |caption| { 'start_time' => caption['start_time'], 'end_time' => caption['end_time'], 'text' => caption['text'] } end end |
#website_visible? ⇒ Boolean
1183 1184 1185 |
# File 'app/models/video.rb', line 1183 def website_visible? !inactive? && PUBLIC_CATEGORIES.include?(category) end |
#youtube_chapters_draft ⇒ Object
167 168 169 170 171 |
# File 'app/models/video.rb', line 167 def youtube_chapters_draft return nil unless has_attribute?(:youtube_chapters_draft) self[:youtube_chapters_draft] end |
#youtube_chapters_generation_error ⇒ Object
161 162 163 164 165 |
# File 'app/models/video.rb', line 161 def youtube_chapters_generation_error return nil unless has_attribute?(:youtube_chapters_generation_error) self[:youtube_chapters_generation_error] end |
#youtube_chapters_generation_in_progress? ⇒ Boolean
173 174 175 |
# File 'app/models/video.rb', line 173 def youtube_chapters_generation_in_progress? youtube_chapters_generation_status.in?(%w[queued processing]) end |
#youtube_chapters_generation_status ⇒ Object
YouTube chapter draft columns (see migration). Until db:migrate has run,
ActiveRecord does not define these accessors — avoid 500s on video#show.
155 156 157 158 159 |
# File 'app/models/video.rb', line 155 def youtube_chapters_generation_status return nil unless has_attribute?(:youtube_chapters_generation_status) self[:youtube_chapters_generation_status] end |
#youtube_local_caption_tracks? ⇒ Boolean
True if we have timed VTT data locally that YouTube::CaptionService can upload (English and/or translations).
1074 1075 1076 1077 1078 |
# File 'app/models/video.rb', line 1074 def youtube_local_caption_tracks? return false unless has_structured_transcript_json? has_polished_vtt? || available_translated_vtt_locales.any? end |
#youtube_thumbnail_image ⇒ Image
Optional Image library asset used only for YouTube custom thumbnail push (overrides poster / Dragonfly / CF still).
226 |
# File 'app/models/video.rb', line 226 belongs_to :youtube_thumbnail_image, class_name: 'Image', optional: true |