Class: Video

Inherits:
DigitalAsset show all
Includes:
Models::CrossLinkable, Models::Embeddable, Models::HybridSearchable, Models::Translatable
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
(DigitalAsset::POPULAR_TAGS - ['no-index']).freeze
'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

DigitalAsset::HIDDEN_TAGS

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Instance Attribute Summary collapse

Attributes included from Models::Translatable

#do_not_compact_translation_container

Attributes inherited from DigitalAsset

#force_new_slug, #refresh_cache, #title, #url

Has many collapse

Methods included from Models::CrossLinkable

#inbound_content_links, #outbound_content_links

Methods included from Models::Embeddable

#content_embeddings

Methods inherited from DigitalAsset

#digital_asset_product_lines, #generated_images, #product_lines, #site_maps

Methods included from Models::Taggable

#tag_records, #taggings

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::CrossLinkable

#content_links_count, #linked_content, #linked_posts, #linked_publications, #linked_showcases, #linked_videos

Methods included from Models::HybridSearchable

rrf_ranked_relation

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

#publish_event

Instance Attribute Details

#categoryObject (readonly)



236
# File 'app/models/video.rb', line 236

validates :category, presence: true

Class Method Details

.ai_searchActiveRecord::Relation<Video>

A relation of Videos that are ai search. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



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_embedding = generate_openai_query_embedding(query)
  unless query_embedding
    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 = query_embedding.size
  vector_literal = "[#{query_embedding.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)

Returns:

  • (Boolean)


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.all_tags(exclude_tags: [])
  super(exclude_tags: exclude_tags + ['no-index'])
end

.by_max_cloudflare_heightActiveRecord::Relation<Video>

A relation of Videos that are by max cloudflare height. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



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_heightActiveRecord::Relation<Video>

A relation of Videos that are by min cloudflare height. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



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_selectObject

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_videosActiveRecord::Relation<Video>

A relation of Videos that are cloudflare videos. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



251
# File 'app/models/video.rb', line 251

scope :cloudflare_videos, -> { where.not(cloudflare_uid: [nil, '']) }

.embeddable_content_typesObject

Embeddable configuration - single unified embedding like Image



1197
1198
1199
# File 'app/models/video.rb', line 1197

def self.embeddable_content_types
  [:primary]
end

.expanded_description_presenceActiveRecord::Relation<Video>

A relation of Videos that are expanded description presence. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



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

Parameters:

  • query (String)

    The search query to embed

  • model (String) (defaults to: 'text-embedding-3-small')

    The embedding model to use

Returns:

  • (Array<Float>, nil)

    The embedding vector, or nil on failure



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.generate_openai_query_embedding(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.embed(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.message}"
  nil
rescue RubyLLM::Error => e
  Rails.logger.error "RubyLLM error generating video query embedding: #{e.message}"
  nil
rescue StandardError => e
  Rails.logger.error "Failed to generate OpenAI query embedding: #{e.message}"
  ErrorReporting.warning(e, context: { query: query.truncate(100), model: model }, reason: 'embedding_generation_failed')
  nil
end

.hosted_videosActiveRecord::Relation<Video>

A relation of Videos that are hosted videos. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



249
# File 'app/models/video.rb', line 249

scope :hosted_videos, -> { where.not(attachment_uid: nil) }

.hosting_typeActiveRecord::Relation<Video>

A relation of Videos that are hosting type. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



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_searchActiveRecord::Relation<Video>

A relation of Videos that are hybrid search. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



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_videosActiveRecord::Relation<Video>

A relation of Videos that are linkable videos. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



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_presenceActiveRecord::Relation<Video>

A relation of Videos that are meta description presence. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



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_presenceActiveRecord::Relation<Video>

A relation of Videos that are meta title presence. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



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_videosActiveRecord::Relation<Video>

A relation of Videos that are public videos. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



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_presenceActiveRecord::Relation<Video>

A relation of Videos that are sub header presence. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



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_presenceActiveRecord::Relation<Video>

A relation of Videos that are transcript presence. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



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_wordsObject

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_transcriptsObject

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_presenceActiveRecord::Relation<Video>

A relation of Videos that are youtube id presence. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



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

Returns:

  • (Boolean)


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_uploadObject



762
763
764
# File 'app/models/video.rb', line 762

def audio_extraction_upload
  uploads.in_category('audio_extraction').first
end

#available_translated_vtt_localesArray<String>

Get all available translated VTT locales

Returns:

  • (Array<String>)

    Array of locale codes that have translations



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

Returns:

  • (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.

Parameters:

  • duration (Integer) (defaults to: 5)

    seconds of video to capture (default: 5)

  • height (Integer) (defaults to: 480)

    output height in pixels (default: 480)

  • fps (Integer) (defaults to: 8)

    frames per second (default: 8, lower = smaller file)

Returns:

  • (String, nil)

    URL to animated GIF or nil if not a Cloudflare 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_urlObject



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_captionsObject

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_urlObject

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_statusObject



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.message}"
  nil
end

#cloudflare_default_download_urlObject

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_durationObject

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_sizeObject

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_dimensionsObject

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_urlObject

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_percentageObject

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

Returns:

  • (Boolean)


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_statusObject

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_urlObject



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_urlObject



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_for_embedding(_content_type = :primary)
  [
    embedding_core_content,
    embedding_product_context,
    embedding_categorization,
    embedding_transcript
  ].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_cloudflareObject

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_mp4Object



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_syncObject

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_cloudflareObject

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_enabledObject

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 extract_poster_at_timestamp(timestamp_seconds = 5.0)
  return false unless is_cloudflare? || is_hosted?

  Rails.logger.info "Extracting poster for video #{id} at #{timestamp_seconds} seconds"

  service = VideoPosterExtractionService.new(self, timestamp_seconds)
  service.extract_poster
rescue VideoPosterExtractionService::ExtractionError => e
  Rails.logger.error "Failed to extract poster for video #{id}: #{e.message}"
  false
end

#fetch_cloudflare_captionsObject

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.message}"
  []
end

#fetch_cloudflare_durationObject



636
637
638
# File 'app/models/video.rb', line 636

def fetch_cloudflare_duration
  cloudflare_duration
end

#fetch_duration_in_secondsObject

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? && attachment&.path.present?
        cmd = "#{ffprobe_location} -i #{attachment.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_locationObject



582
583
584
# File 'app/models/video.rb', line 582

def ffmpeg_location
  @ffmpeg_location ||= `which ffmpeg`.presence.strip || '/usr/bin/ffmpeg'
end

#ffprobe_locationObject



586
587
588
# File 'app/models/video.rb', line 586

def ffprobe_location
  @ffprobe_location ||= `which ffprobe`.presence.strip || '/usr/bin/ffprobe'
end

#frames_countObject

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 #{attachment.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

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

Returns:

  • (Boolean)


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

Returns:

  • (Boolean)


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)

Returns:

  • (Boolean)


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

Returns:

  • (Boolean)


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

Returns:

  • (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

Returns:

  • (Boolean)


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

Returns:

  • (Boolean)


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

Returns:

  • (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

Returns:

  • (Boolean)


766
767
768
# File 'app/models/video.rb', line 766

def has_seo_data?
  meta_title.present? && meta_description.present? && expanded_description.present?
end

#has_structured_transcript_json?Boolean

Returns:

  • (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

Parameters:

  • locale (String, Symbol)

    Locale code (e.g., 'fr-CA', :pl)

Returns:

  • (Boolean)

    True if translated VTT captions exist



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

Returns:

  • (Boolean)


1179
1180
1181
# File 'app/models/video.rb', line 1179

def indexed_video?
  !inactive? && PRIVATE_CATEGORIES.exclude?(category)
end

#is_cloudflare?Boolean

Returns:

  • (Boolean)


447
448
449
# File 'app/models/video.rb', line 447

def is_cloudflare?
  cloudflare_uid.present?
end

#is_hosted?Boolean

Returns:

  • (Boolean)


451
452
453
# File 'app/models/video.rb', line 451

def is_hosted?
  attachment_uid.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_captionsObject

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_dataObject



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_imageImage

Returns:

See Also:



224
# File 'app/models/video.rb', line 224

belongs_to :poster_image, class_name: 'Image', optional: true

#refresh_cloudflare_dataObject

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_durationObject



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


1187
1188
1189
1190
1191
1192
1193
1194
# File 'app/models/video.rb', line 1187

def related_sources
  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_videosActiveRecord::Relation<ReviewsIoVideo>

Returns:

See Also:



222
# File 'app/models/video.rb', line 222

has_many :reviews_io_videos, dependent: :destroy

#reviews_iosActiveRecord::Relation<ReviewsIo>

Returns:

See Also:



223
# File 'app/models/video.rb', line 223

has_many :reviews_ios, through: :reviews_io_videos

#sentences_dataObject



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_secondsObject



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_attachmentObject



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 set_poster_from_attachment
  return if attachment.blank?

  new_name = File.basename(attachment.name, '.*')
  temp_path = Rails.root.join(Rails.application.config.x.temp_storage_path.to_s, "#{new_name}_#{Time.current.to_i}.jpg")
  options = {}
  options[:seek_time] = poster_offset.to_i / 1000 if poster_offset.present?
  streamio_movie.screenshot(temp_path.to_s, **options)

  # 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.full_messages}"
  end
end

#should_skip_transcription?Boolean

Returns:

  • (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_movieObject



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(attachment.path)
end

#structured_transcript_metadataObject



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_paragraphsObject

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_statsObject



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_sObject



620
621
622
# File 'app/models/video.rb', line 620

def to_s
  "#{title} [#{id}]"
end

#transcript_word_countObject

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

#translationsObject

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: #{meta_title.present?}"
  Rails.logger.info "  - meta_description present: #{meta_description.present?}"
  Rails.logger.info "  - expanded_description present: #{expanded_description.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 meta_title.blank? && seo_content['meta_title'].present?
      Rails.logger.info 'Will update meta_title'
      update_attributes << :meta_title
    end

    if meta_description.blank? && seo_content['meta_description'].present?
      Rails.logger.info 'Will update meta_description'
      update_attributes << :meta_description
    end

    if expanded_description.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 meta_title.blank? && seo_content['meta_title'].present?
    update_hash[:meta_description] = seo_content['meta_description'] if meta_description.blank? && seo_content['meta_description'].present?
    update_hash[:expanded_description] = seo_content['expanded_description'] if expanded_description.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

#uploadsActiveRecord::Relation<Upload>

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



221
# File 'app/models/video.rb', line 221

has_many :uploads, as: :resource, dependent: :destroy

#video_embeddingsActiveRecord::Relation<ContentEmbedding::VideoEmbedding>

Association to the partitioned embeddings table for AI search

Returns:

See Also:



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_dataObject



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_displayObject



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_dataObject



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_displayObject



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

Parameters:

  • locale (String, Symbol)

    Locale code (e.g., 'fr-CA', :pl)

Returns:

  • (Array<Hash>, nil)

    Array of caption hashes or nil if not found



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

Parameters:

  • locale (String, Symbol)

    Locale code (e.g., 'fr-CA', :pl)

Returns:

  • (Array<Hash>)

    Array of formatted caption hashes



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

Returns:

  • (Boolean)


1183
1184
1185
# File 'app/models/video.rb', line 1183

def website_visible?
  !inactive? && PUBLIC_CATEGORIES.include?(category)
end

#youtube_chapters_draftObject



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_errorObject



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

Returns:

  • (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_statusObject

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).

Returns:

  • (Boolean)


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_imageImage

Optional Image library asset used only for YouTube custom thumbnail push (overrides poster / Dragonfly / CF still).

Returns:

See Also:



226
# File 'app/models/video.rb', line 226

belongs_to :youtube_thumbnail_image, class_name: 'Image', optional: true