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 Schedulable

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

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, #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, #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, valid, 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 Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#categoryObject (readonly)



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

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:



339
340
341
342
343
344
345
346
347
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
# File 'app/models/video.rb', line 339

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)


390
391
392
# File 'app/models/video.rb', line 390

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



441
442
443
# File 'app/models/video.rb', line 441

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:



313
314
315
316
317
# File 'app/models/video.rb', line 313

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:



307
308
309
310
311
# File 'app/models/video.rb', line 307

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"], ...] }



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

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



395
396
397
# File 'app/models/video.rb', line 395

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:



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

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

.embeddable_content_typesObject

Embeddable configuration - single unified embedding like Image



1214
1215
1216
# File 'app/models/video.rb', line 1214

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:



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

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:



280
281
282
# File 'app/models/video.rb', line 280

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:



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

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:



285
286
287
288
289
290
291
292
293
294
295
# File 'app/models/video.rb', line 285

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:



410
411
412
413
414
415
416
417
418
419
420
421
# File 'app/models/video.rb', line 410

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

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



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

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



863
864
865
# File 'app/models/video.rb', line 863

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:



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

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:



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

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:



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

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

.purge_edge_cache(index_only: true) ⇒ Object



399
400
401
402
403
404
405
406
407
# File 'app/models/video.rb', line 399

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



423
424
425
426
427
428
# File 'app/models/video.rb', line 423

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:



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

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:



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

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)



856
857
858
859
860
# File 'app/models/video.rb', line 856

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



851
852
853
# File 'app/models/video.rb', line 851

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:



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

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:



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

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)


552
553
554
555
556
# File 'app/models/video.rb', line 552

def audio_download_ready?
  return false unless has_audio_download?

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

#audio_extraction_uploadObject



779
780
781
# File 'app/models/video.rb', line 779

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



1109
1110
1111
1112
1113
1114
1115
# File 'app/models/video.rb', line 1109

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)


818
819
820
# File 'app/models/video.rb', line 818

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.



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

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



491
492
493
494
495
# File 'app/models/video.rb', line 491

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



460
461
462
# File 'app/models/video.rb', line 460

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

#cloudflare_all_captionsObject

Get all captions currently on Cloudflare



878
879
880
881
882
883
884
885
886
887
888
889
890
# File 'app/models/video.rb', line 878

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



566
567
568
569
570
# File 'app/models/video.rb', line 566

def cloudflare_audio_download_url
  return nil unless has_audio_download?

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

#cloudflare_captions_statusObject



867
868
869
870
871
872
873
874
875
# File 'app/models/video.rb', line 867

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



559
560
561
562
563
# File 'app/models/video.rb', line 559

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



573
574
575
576
577
# File 'app/models/video.rb', line 573

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



1059
1060
1061
1062
1063
# File 'app/models/video.rb', line 1059

def cloudflare_duration
  return nil unless is_cloudflare?

  cloudflare_data['duration']
end

#cloudflare_file_sizeObject

Get Cloudflare video file size from cached data



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

def cloudflare_file_size
  return nil unless is_cloudflare?

  cloudflare_data['size']
end

#cloudflare_input_dimensionsObject

Get Cloudflare video input dimensions from cached data



1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
# File 'app/models/video.rb', line 1046

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



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

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



1029
1030
1031
1032
1033
1034
1035
1036
# File 'app/models/video.rb', line 1029

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)


1022
1023
1024
1025
1026
# File 'app/models/video.rb', line 1022

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



1015
1016
1017
1018
1019
# File 'app/models/video.rb', line 1015

def cloudflare_status
  return nil unless is_cloudflare?

  cloudflare_data['status']
end

#cloudflare_video_urlObject



497
498
499
500
501
502
# File 'app/models/video.rb', line 497

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



633
634
635
# File 'app/models/video.rb', line 633

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



1220
1221
1222
1223
1224
1225
1226
1227
# File 'app/models/video.rb', line 1220

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



800
801
802
803
804
805
806
807
808
809
810
811
# File 'app/models/video.rb', line 800

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



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

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



521
522
523
524
525
# File 'app/models/video.rb', line 521

def delete_mp4_download(download_type)
  return false unless is_cloudflare?

  CloudflareStreamApi.instance.delete_mp4_download(cloudflare_uid, download_type)
end

#enable_mp4Object



511
512
513
# File 'app/models/video.rb', line 511

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)



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

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



965
966
967
968
969
970
971
972
# File 'app/models/video.rb', line 965

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



975
976
977
978
979
980
981
982
# File 'app/models/video.rb', line 975

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)



528
529
530
531
532
533
534
535
# File 'app/models/video.rb', line 528

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



985
986
987
988
989
990
991
992
993
994
995
# File 'app/models/video.rb', line 985

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)



893
894
895
896
897
898
899
900
# File 'app/models/video.rb', line 893

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



653
654
655
# File 'app/models/video.rb', line 653

def fetch_cloudflare_duration
  cloudflare_duration
end

#fetch_duration_in_secondsObject

Probe the video for its duration



642
643
644
645
646
647
648
649
650
651
# File 'app/models/video.rb', line 642

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



599
600
601
# File 'app/models/video.rb', line 599

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

#ffprobe_locationObject



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

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

#frames_countObject

Count the number of frames in the video



673
674
675
676
677
678
679
# File 'app/models/video.rb', line 673

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)



591
592
593
594
595
596
597
# File 'app/models/video.rb', line 591

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)


814
815
816
# File 'app/models/video.rb', line 814

def has_assemblyai_transcript_id?
  assemblyai_transcript_id.present?
end

#has_audio_download?Boolean

Check if audio download is enabled

Returns:

  • (Boolean)


545
546
547
548
549
# File 'app/models/video.rb', line 545

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)


788
789
790
# File 'app/models/video.rb', line 788

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)


903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
# File 'app/models/video.rb', line 903

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)


792
793
794
# File 'app/models/video.rb', line 792

def has_cloudflare_captions?
  cloudflare_captions_status.present?
end

#has_default_download?Boolean

Check if default video download is enabled

Returns:

  • (Boolean)


538
539
540
541
542
# File 'app/models/video.rb', line 538

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)


846
847
848
# File 'app/models/video.rb', line 846

def has_no_spoken_words?
  video_has_no_spoken_words?
end

#has_polished_vtt?Boolean

Returns:

  • (Boolean)


1084
1085
1086
1087
1088
# File 'app/models/video.rb', line 1084

def has_polished_vtt?
  return false if structured_transcript_json.blank?

  structured_transcript_json['vtt_polished'].present?
end

#has_seo_data?Boolean

Returns:

  • (Boolean)


783
784
785
# File 'app/models/video.rb', line 783

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

#has_structured_transcript_json?Boolean

Returns:

  • (Boolean)


796
797
798
# File 'app/models/video.rb', line 796

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



1100
1101
1102
1103
1104
1105
# File 'app/models/video.rb', line 1100

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)


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

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

#is_cloudflare?Boolean

Returns:

  • (Boolean)


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

def is_cloudflare?
  cloudflare_uid.present?
end

#is_hosted?Boolean

Returns:

  • (Boolean)


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

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



836
837
838
# File 'app/models/video.rb', line 836

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



924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
# File 'app/models/video.rb', line 924

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



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

def original_transcript_data
  return nil if structured_transcript_json.blank?

  structured_transcript_json['original_transcript']
end

#poster_imageImage

Returns:

See Also:



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

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.



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

def primary_product_line
  product_lines.first
end

#refresh_cloudflare_dataObject

Refresh and cache Cloudflare video data including downloads information and captions



941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
# File 'app/models/video.rb', line 941

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



663
664
665
666
667
668
669
670
# File 'app/models/video.rb', line 663

def refresh_duration
  if set_duration_in_seconds
    save!
    true
  else
    false
  end
end


1204
1205
1206
1207
1208
1209
1210
1211
# File 'app/models/video.rb', line 1204

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:



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

has_many :reviews_io_videos, dependent: :destroy

#reviews_iosActiveRecord::Relation<ReviewsIo>

Returns:

See Also:



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

has_many :reviews_ios, through: :reviews_io_videos

#sentences_dataObject



1184
1185
1186
1187
1188
# File 'app/models/video.rb', line 1184

def sentences_data
  return nil if structured_transcript_json.blank?

  structured_transcript_json['sentences']
end

#set_duration_in_secondsObject



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

def set_duration_in_seconds
  return unless (d = fetch_duration_in_seconds)

  self.duration_in_seconds = d
end

#set_poster_from_attachmentObject



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

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)


822
823
824
825
826
827
828
829
830
831
832
833
# File 'app/models/video.rb', line 822

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



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

def streamio_movie
  return unless is_hosted?

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

#structured_transcript_metadataObject



1073
1074
1075
1076
1077
# File 'app/models/video.rb', line 1073

def 
  return {} if structured_transcript_json.blank?

  structured_transcript_json['metadata'] || {}
end

#structured_transcript_paragraphsObject

Structured transcript JSON methods



1067
1068
1069
1070
1071
# File 'app/models/video.rb', line 1067

def structured_transcript_paragraphs
  return [] if structured_transcript_json.blank?

  structured_transcript_json['paragraphs'] || []
end

#structured_transcript_vtt_statsObject



1079
1080
1081
1082
# File 'app/models/video.rb', line 1079

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



579
580
581
582
583
584
585
586
587
588
# File 'app/models/video.rb', line 579

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



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

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

#transcript_word_countObject

Transcript methods



682
683
684
685
686
# File 'app/models/video.rb', line 682

def transcript_word_count
  return 0 if transcript.blank?

  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)



841
842
843
# File 'app/models/video.rb', line 841

def unmark_as_no_spoken_words!
  update!(video_has_no_spoken_words: false)
end

#update_transcript_data(result) ⇒ Object



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
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
# File 'app/models/video.rb', line 688

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:



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

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

#video_chaptersActiveRecord::Relation<VideoChapter>

Returns:

See Also:



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

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:



242
243
244
# File 'app/models/video.rb', line 242

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

#vtt_original_dataObject



1142
1143
1144
1145
1146
# File 'app/models/video.rb', line 1142

def vtt_original_data
  return [] if structured_transcript_json.blank?

  structured_transcript_json['vtt_original'] || []
end

#vtt_original_text_for_displayObject



1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
# File 'app/models/video.rb', line 1148

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



1163
1164
1165
1166
1167
# File 'app/models/video.rb', line 1163

def vtt_polished_data
  return [] if structured_transcript_json.blank?

  structured_transcript_json['vtt_polished'] || []
end

#vtt_polished_text_for_displayObject



1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
# File 'app/models/video.rb', line 1169

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



1120
1121
1122
1123
1124
1125
# File 'app/models/video.rb', line 1120

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



1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
# File 'app/models/video.rb', line 1130

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)


1200
1201
1202
# File 'app/models/video.rb', line 1200

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

#youtube_chapters_generation_errorObject



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

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)


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

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.



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

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)


1091
1092
1093
1094
1095
# File 'app/models/video.rb', line 1091

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:



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

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