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 :enum 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 =

Private categories.

%w[ugc unlisted].freeze
PUBLIC_CATEGORIES =

Public categories.

%w[installation news product testimonial training webinar].freeze
CATEGORIES =

Categories.

PRIVATE_CATEGORIES + PUBLIC_CATEGORIES
TRANSCRIPT_COLUMNS =

Heavy per-row text columns that only a video's own watch page needs.
structured_transcript_json alone can be ~500KB/row, so a plain SELECT *
across a card grid / listing detoasts tens of MB into Ruby. Card and
listing scopes omit these via without_transcript_columns; the schema
guard in VideoBasePresenter#build_transcript_schema then drops the
transcript from VideoObject markup when they weren't selected.

%w[structured_transcript_json transcript].freeze
(DigitalAsset::POPULAR_TAGS - ['no-index']).freeze
'for-product-page: product page video carousel. for-support-page: support section.'

Constants included from Models::Embeddable

Models::Embeddable::MAX_CONTENT_LENGTH

Constants inherited from DigitalAsset

DigitalAsset::HIDDEN_TAGS

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

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

Delegated Instance Attributes collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::CrossLinkable

#case_evidence, #content_links_count, #linked_articles, #linked_content, #linked_faqs, #linked_posts, #linked_publications, #linked_showcases, #linked_videos

Methods included from Models::HybridSearchable

ranked_ids, rrf_ranked_relation

Methods included from Models::Embeddable

#embeddable_locales, #embedding_content_hash, #embedding_current?, #embedding_eligible?, 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, with_shape_lock

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, by_product_line_path_excluding, categorized, #cross_links_opportunities_to_parties, #dimensions, exclude_tags, #externally_referenced?, #file_basename, images, #invalidate!, invalidated, #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, 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, valid, videos, with_product_line_urls, without_product_categories, without_product_lines

Methods included from Models::EdgeCachePurgeable

#edge_cache_urls, #enqueue_edge_cache_purge, #purge_edge_cache

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::Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#categoryObject (readonly)



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

validates :category, presence: true

#youtube_idObject (readonly)

A YouTube ID is always 11 chars from [A-Za-z0-9_-]; reject anything else so a
truncated/garbage id can't silently 404 every push/sync (AppSignal-free bug
found 2026-07-08: two ids had a leading _ stripped on import).

Validations:

  • Format ({ with: Heatwave::Normalizers::YOUTUBE_ID_REGEXP, message: 'must be an 11-character YouTube video ID (letters, digits, _ or -)' })
  • Allow_blank


255
256
257
258
# File 'app/models/video.rb', line 255

validates :youtube_id,
format: { with: Heatwave::Normalizers::YOUTUBE_ID_REGEXP,
          message: 'must be an 11-character YouTube video ID (letters, digits, _ or -)' },
allow_blank: 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:



348
349
350
351
352
353
354
355
356
357
358
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
387
388
389
390
391
392
393
# File 'app/models/video.rb', line 348

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 via Gemini (matches stored unified video vectors).
  query_embedding = ContentEmbedding.generate_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 (unified Gemini vectors).
  base_query = joins(:video_embeddings)
               .where(content_embeddings_videos: { content_type: 'unified', embedding_model: ContentEmbedding::UNIFIED_MODELS })
               .where.not(content_embeddings_videos: { unified_embedding: nil })
               .select(
                 "#{table_name}.*",
                 Arel.sql(sanitize_sql_array([
                                               "content_embeddings_videos.unified_embedding::vector(#{dimensions}) <=> ?::vector(#{dimensions}) AS neighbor_distance",
                                               vector_literal
                                             ]))
               )
               .order(Arel.sql(sanitize_sql_array([
                                                    "content_embeddings_videos.unified_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.unified_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)


399
400
401
# File 'app/models/video.rb', line 399

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



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

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:



322
323
324
325
326
# File 'app/models/video.rb', line 322

scope :by_max_cloudflare_height, ->(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:



316
317
318
319
320
# File 'app/models/video.rb', line 316

scope :by_min_cloudflare_height, ->(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"], ...] }



442
443
444
445
446
447
# File 'app/models/video.rb', line 442

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



404
405
406
# File 'app/models/video.rb', line 404

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:



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

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

.embeddable_content_typesObject

Embeddable configuration - single unified embedding like Image



1063
1064
1065
# File 'app/models/video.rb', line 1063

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:



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

scope :expanded_description_presence, ->(param) { param.to_b ? where.not(expanded_description: [nil, '']) : where(expanded_description: [nil, '']) }

.for_card_gridActiveRecord::Relation<Video>

A relation of Videos that are for card grid. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



289
290
291
# File 'app/models/video.rb', line 289

scope :for_card_grid, -> {
  without_transcript_columns.includes(:poster_image, :product_lines)
}

.hosted_videosActiveRecord::Relation<Video>

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

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



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

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:



294
295
296
297
298
299
300
301
302
303
304
# File 'app/models/video.rb', line 294

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:



419
420
421
422
423
424
425
426
427
428
429
430
# File 'app/models/video.rb', line 419

scope :hybrid_search, ->(query, limit: 500) {
  return none if query.blank?

  ai_ids = ranked_ids(ai_search(query, limit: limit))
  keyword_ids = begin
    ranked_ids(keyword_search(query).limit(limit))
  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:



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

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



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

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:



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

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:



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

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:



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

scope :public_videos, -> { active.where(category: PUBLIC_CATEGORIES).where.not(slug: nil).cloudflare_videos }

.purge_edge_cache(index_only: true) ⇒ Object



408
409
410
411
412
413
414
415
416
# File 'app/models/video.rb', line 408

def self.purge_edge_cache(index_only: true)
  site_maps = SiteMap.where(category: 'video')
  urls = if index_only
           site_maps.where("path LIKE '%/videos'").map(&:url)
         else
           site_maps.map(&:url)
         end
  EdgeCacheWorker.perform_async('urls' => urls) if urls.present?
end

.ransackable_scopes(_auth_object = nil) ⇒ Object



432
433
434
435
436
437
# File 'app/models/video.rb', line 432

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:



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

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:



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

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)



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

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



755
756
757
# File 'app/models/video.rb', line 755

def self.videos_with_assemblyai_transcripts
  where.not(assemblyai_transcript_id: nil)
end

.without_transcript_columnsActiveRecord::Relation<Video>

A relation of Videos that are without transcript columns. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



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

scope :without_transcript_columns, -> { select(Video.column_names - TRANSCRIPT_COLUMNS) }

.youtube_id_presenceActiveRecord::Relation<Video>

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

Returns:

  • (ActiveRecord::Relation<Video>)

See Also:



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

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)


556
557
558
559
560
# File 'app/models/video.rb', line 556

def audio_download_ready?
  return false unless has_audio_download?

  cloudflare_download_status('audio') == 'ready'
end

#audio_extraction_uploadObject



694
695
696
# File 'app/models/video.rb', line 694

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



958
959
960
961
962
963
964
# File 'app/models/video.rb', line 958

def available_translated_vtt_locales
  return [] if structured_transcript_json.blank?

  VideoProcessing::VideoTranslationService::SUPPORTED_LOCALES.keys.select do |locale|
    has_translated_vtt?(locale)
  end
end

#can_retrieve_existing_transcript?Boolean

Returns:

  • (Boolean)


722
723
724
# File 'app/models/video.rb', line 722

def can_retrieve_existing_transcript?
  has_assemblyai_transcript_id? && transcript.blank?
end

#canonical_pillar_pathObject

Locale-less canonical path nesting the video under its pillar product
line, e.g. "floor-heating/videos/how-to-install". Returns nil when the
video has no product line rooted in a catalog root slug; callers then
fall back to the flat "video-media/" path.



485
486
487
488
489
490
# File 'app/models/video.rb', line 485

def canonical_pillar_path
  base = primary_product_line&.canonical_path.presence
  return unless base && CatalogPathResolver::PRODUCT_LINE_ROOT_SLUGS.include?(base.split('/').first)

  "#{base}/videos/#{slug}"
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



500
501
502
503
504
# File 'app/models/video.rb', line 500

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



469
470
471
# File 'app/models/video.rb', line 469

def cf_poster_url
  "#{CF_STREAM_URL}/#{cloudflare_uid}/thumbnails/thumbnail.jpg"
end

#cloudflare_all_captionsObject

Get all captions currently on Cloudflare



776
777
778
779
780
781
782
783
784
785
786
787
788
# File 'app/models/video.rb', line 776

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



570
571
572
573
574
# File 'app/models/video.rb', line 570

def cloudflare_audio_download_url
  return nil unless has_audio_download?

  cloudflare_data.dig('downloads', 'audio', 'url')
end

#cloudflare_caption_for_locale(locale) ⇒ Object

Get a cached Cloudflare caption for a locale.



791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
# File 'app/models/video.rb', line 791

def cloudflare_caption_for_locale(locale)
  return 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.find { |caption| caption['language'] == cloudflare_lang }
end

#cloudflare_captions_statusObject



771
772
773
# File 'app/models/video.rb', line 771

def cloudflare_captions_status
  cloudflare_operations.captions_status
end

#cloudflare_default_download_urlObject

Get default video download URL from Cloudflare data



563
564
565
566
567
# File 'app/models/video.rb', line 563

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



577
578
579
580
581
# File 'app/models/video.rb', line 577

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



909
910
911
912
913
# File 'app/models/video.rb', line 909

def cloudflare_duration
  return nil unless is_cloudflare?

  cloudflare_data['duration']
end

#cloudflare_file_sizeObject

Get Cloudflare video file size from cached data



889
890
891
892
893
# File 'app/models/video.rb', line 889

def cloudflare_file_size
  return nil unless is_cloudflare?

  cloudflare_data['size']
end

#cloudflare_input_dimensionsObject

Get Cloudflare video input dimensions from cached data



896
897
898
899
900
901
902
903
904
905
906
# File 'app/models/video.rb', line 896

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



514
515
516
517
518
# File 'app/models/video.rb', line 514

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



879
880
881
882
883
884
885
886
# File 'app/models/video.rb', line 879

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)


872
873
874
875
876
# File 'app/models/video.rb', line 872

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



865
866
867
868
869
# File 'app/models/video.rb', line 865

def cloudflare_status
  return nil unless is_cloudflare?

  cloudflare_data['status']
end

#cloudflare_video_urlObject



506
507
508
509
510
511
# File 'app/models/video.rb', line 506

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



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

def cms_url
  @cms_url ||= "#{WEB_URL}#{Web::UrlBuilder.new.process("/#{canonical_pillar_path || "video-media/#{slug}"}")}"
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



1069
1070
1071
1072
1073
1074
1075
1076
# File 'app/models/video.rb', line 1069

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_uploadObject

Alias for Transcript_processor#create_audio_extraction_upload

Returns:

  • (Object)

    Transcript_processor#create_audio_extraction_upload

See Also:



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

delegate :create_audio_extraction_upload, to: :transcript_processor

#delete_from_cloudflareObject



860
861
862
# File 'app/models/video.rb', line 860

def delete_from_cloudflare
  cloudflare_operations.delete_video
end

#delete_mp4_download(download_type) ⇒ Object



533
534
535
# File 'app/models/video.rb', line 533

def delete_mp4_download(download_type)
  cloudflare_operations.delete_download(download_type)
end

#duration_msInteger

Video length in milliseconds (0 when the duration is unknown). Single basis
for the out-of-bounds chapter rule enforced in VideoChapter and
YouTube::ChapterService, so the * 1000 conversion can't drift between them.

Returns:

  • (Integer)


525
526
527
# File 'app/models/video.rb', line 525

def duration_ms
  duration_in_seconds.to_i * 1000
end

#enable_mp4_download(download_type) ⇒ Object



529
530
531
# File 'app/models/video.rb', line 529

def enable_mp4_download(download_type)
  cloudflare_operations.enable_download(download_type)
end

#enqueue_cloudflare_sync_if_newly_cloudflareObject

Enqueue Cloudflare synchronization when a video is updated to use Cloudflare



838
839
840
841
842
843
844
845
# File 'app/models/video.rb', line 838

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



537
538
539
# File 'app/models/video.rb', line 537

def ensure_mp4_downloads_enabled
  cloudflare_operations.ensure_downloads_enabled
end

#extract_poster_at_timestamp(timestamp_seconds = 5.0) ⇒ Object

Extract a poster frame from the video at the specified timestamp



848
849
850
851
852
853
854
855
856
857
858
# File 'app/models/video.rb', line 848

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_durationObject



657
658
659
# File 'app/models/video.rb', line 657

def fetch_cloudflare_duration
  cloudflare_duration
end

#fetch_duration_in_secondsObject

Probe the video for its duration



646
647
648
649
650
651
652
653
654
655
# File 'app/models/video.rb', line 646

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



603
604
605
# File 'app/models/video.rb', line 603

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

#ffprobe_locationObject



607
608
609
# File 'app/models/video.rb', line 607

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

#frames_countObject

Count the number of frames in the video



677
678
679
680
681
682
683
# File 'app/models/video.rb', line 677

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)



595
596
597
598
599
600
601
# File 'app/models/video.rb', line 595

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)


718
719
720
# File 'app/models/video.rb', line 718

def has_assemblyai_transcript_id?
  assemblyai_transcript_id.present?
end

#has_audio_download?Boolean

Check if audio download is enabled

Returns:

  • (Boolean)


549
550
551
552
553
# File 'app/models/video.rb', line 549

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)


703
704
705
# File 'app/models/video.rb', line 703

def has_audio_extraction?
  audio_extraction_upload.present?
end

#has_cloudflare_caption_for_locale?(locale) ⇒ Boolean

Check if a specific language caption exists in the cached Cloudflare data.

Returns:

  • (Boolean)


812
813
814
# File 'app/models/video.rb', line 812

def has_cloudflare_caption_for_locale?(locale)
  cloudflare_caption_for_locale(locale).present?
end

#has_cloudflare_captions?Boolean

Returns:

  • (Boolean)


707
708
709
# File 'app/models/video.rb', line 707

def has_cloudflare_captions?
  has_cloudflare_caption_for_locale?('en')
end

#has_default_download?Boolean

Check if default video download is enabled

Returns:

  • (Boolean)


542
543
544
545
546
# File 'app/models/video.rb', line 542

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)


750
751
752
# File 'app/models/video.rb', line 750

def has_no_spoken_words?
  video_has_no_spoken_words?
end

#has_polished_vtt?Boolean

Returns:

  • (Boolean)


933
934
935
936
937
# File 'app/models/video.rb', line 933

def has_polished_vtt?
  return false if structured_transcript_json.blank?

  structured_transcript_json['vtt_polished'].present?
end

#has_seo_data?Boolean

Returns:

  • (Boolean)


698
699
700
# File 'app/models/video.rb', line 698

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

#has_structured_transcript_json?Boolean

Returns:

  • (Boolean)


711
712
713
# File 'app/models/video.rb', line 711

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



949
950
951
952
953
954
# File 'app/models/video.rb', line 949

def has_translated_vtt?(locale)
  return false if structured_transcript_json.blank?

  vtt_key = "vtt_#{locale.to_s.underscore}"
  structured_transcript_json[vtt_key].present?
end

#indexed_video?Boolean

Returns:

  • (Boolean)


1045
1046
1047
# File 'app/models/video.rb', line 1045

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

#is_cloudflare?Boolean

Returns:

  • (Boolean)


454
455
456
# File 'app/models/video.rb', line 454

def is_cloudflare?
  cloudflare_uid.present?
end

#is_hosted?Boolean

Returns:

  • (Boolean)


458
459
460
# File 'app/models/video.rb', line 458

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



740
741
742
# File 'app/models/video.rb', line 740

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



817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
# File 'app/models/video.rb', line 817

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



1039
1040
1041
1042
1043
# File 'app/models/video.rb', line 1039

def original_transcript_data
  return nil if structured_transcript_json.blank?

  structured_transcript_json['original_transcript']
end

#poster_imageImage

Returns:

See Also:



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

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

#primary_product_lineObject

The product line that supplies this video's canonical pillar URL.
Videos may belong to several product lines; the first (lowest
digital_asset_product_lines.position) is canonical — the same one
that drives the breadcrumb.



477
478
479
# File 'app/models/video.rb', line 477

def primary_product_line
  product_lines.first
end

#refresh_cloudflare_dataObject



833
834
835
# File 'app/models/video.rb', line 833

def refresh_cloudflare_data
  cloudflare_operations.refresh_data
end

#refresh_durationObject



667
668
669
670
671
672
673
674
# File 'app/models/video.rb', line 667

def refresh_duration
  if set_duration_in_seconds
    save!
    true
  else
    false
  end
end


1053
1054
1055
1056
1057
1058
1059
1060
# File 'app/models/video.rb', line 1053

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:



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

has_many :reviews_io_videos, dependent: :destroy

#reviews_iosActiveRecord::Relation<ReviewsIo>

Returns:

See Also:



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

has_many :reviews_ios, through: :reviews_io_videos

#sentences_dataObject



1033
1034
1035
1036
1037
# File 'app/models/video.rb', line 1033

def sentences_data
  return nil if structured_transcript_json.blank?

  structured_transcript_json['sentences']
end

#set_duration_in_secondsObject



661
662
663
664
665
# File 'app/models/video.rb', line 661

def set_duration_in_seconds
  return unless (d = fetch_duration_in_seconds)

  self.duration_in_seconds = d
end

#set_poster_from_attachmentObject



611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
# File 'app/models/video.rb', line 611

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)


726
727
728
729
730
731
732
733
734
735
736
737
# File 'app/models/video.rb', line 726

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



462
463
464
465
466
467
# File 'app/models/video.rb', line 462

def streamio_movie
  return unless is_hosted?

  require 'streamio-ffmpeg'
  FFMPEG::Movie.new(attachment.path)
end

#structured_transcript_metadataObject



922
923
924
925
926
# File 'app/models/video.rb', line 922

def 
  return {} if structured_transcript_json.blank?

  structured_transcript_json['metadata'] || {}
end

#structured_transcript_paragraphsObject

Structured transcript JSON methods



916
917
918
919
920
# File 'app/models/video.rb', line 916

def structured_transcript_paragraphs
  return [] if structured_transcript_json.blank?

  structured_transcript_json['paragraphs'] || []
end

#structured_transcript_vtt_statsObject



928
929
930
931
# File 'app/models/video.rb', line 928

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



583
584
585
586
587
588
589
590
591
592
# File 'app/models/video.rb', line 583

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



641
642
643
# File 'app/models/video.rb', line 641

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

#transcript_word_countObject

Transcript methods



686
687
688
689
690
# File 'app/models/video.rb', line 686

def transcript_word_count
  return 0 if transcript.blank?

  transcript.split(/\s+/).count
end

#translationsObject

Override translations accessor to ensure it's never nil



125
126
127
# File 'app/models/video.rb', line 125

def translations
  super || {}
end

#translations=(value) ⇒ Object



129
130
131
# File 'app/models/video.rb', line 129

def translations=(value)
  super(value || {})
end

#unmark_as_no_spoken_words!Object

Unmark video as having no spoken words (if it was incorrectly marked)



745
746
747
# File 'app/models/video.rb', line 745

def unmark_as_no_spoken_words!
  update!(video_has_no_spoken_words: false)
end

#update_transcript_dataObject

Alias for Transcript_processor#update_transcript_data

Returns:

  • (Object)

    Transcript_processor#update_transcript_data

See Also:



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

delegate :update_transcript_data, to: :transcript_processor

#uploadsActiveRecord::Relation<Upload>

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



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

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

#video_chaptersActiveRecord::Relation<VideoChapter>

Returns:

See Also:



183
184
185
186
# File 'app/models/video.rb', line 183

has_many :video_chapters,
foreign_key: :digital_asset_id,
inverse_of: :video,
dependent: :destroy

#video_embeddingsActiveRecord::Relation<ContentEmbedding::VideoEmbedding>

Association to the partitioned embeddings table for AI search

Returns:

See Also:



244
245
246
# File 'app/models/video.rb', line 244

has_many :video_embeddings, -> { where(embeddable_type: 'Video') },
class_name: 'ContentEmbedding::VideoEmbedding',
foreign_key: :embeddable_id

#vtt_original_dataObject



991
992
993
994
995
# File 'app/models/video.rb', line 991

def vtt_original_data
  return [] if structured_transcript_json.blank?

  structured_transcript_json['vtt_original'] || []
end

#vtt_original_text_for_displayObject



997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
# File 'app/models/video.rb', line 997

def vtt_original_text_for_display
  return [] if structured_transcript_json.blank?

  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



1012
1013
1014
1015
1016
# File 'app/models/video.rb', line 1012

def vtt_polished_data
  return [] if structured_transcript_json.blank?

  structured_transcript_json['vtt_polished'] || []
end

#vtt_polished_text_for_displayObject



1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
# File 'app/models/video.rb', line 1018

def vtt_polished_text_for_display
  return [] if structured_transcript_json.blank?

  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



969
970
971
972
973
974
# File 'app/models/video.rb', line 969

def vtt_translated_data(locale)
  return nil if structured_transcript_json.blank?

  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



979
980
981
982
983
984
985
986
987
988
989
# File 'app/models/video.rb', line 979

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)


1049
1050
1051
# File 'app/models/video.rb', line 1049

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

#youtube_chapters_generation_errorObject



173
174
175
176
177
# File 'app/models/video.rb', line 173

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)


179
180
181
# File 'app/models/video.rb', line 179

def youtube_chapters_generation_in_progress?
  youtube_chapters_generation_status.in?(%w[queued processing])
end

#youtube_chapters_generation_statusObject

YouTube chapter columns (see migration). Until db:migrate has run,
ActiveRecord does not define these accessors — avoid 500s on video#show.



167
168
169
170
171
# File 'app/models/video.rb', line 167

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)


940
941
942
943
944
# File 'app/models/video.rb', line 940

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:



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

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