Class: Video
- Inherits:
-
DigitalAsset
- Object
- ActiveRecord::Base
- ApplicationRecord
- DigitalAsset
- Video
- Defined in:
- app/models/video.rb
Overview
== Schema Information
Table name: digital_assets
Database name: primary
id :integer not null, primary key
ai_metadata_suggestions :jsonb
ai_visual_description :text
air_date :date
asset :jsonb
attachment_format :string(10)
attachment_height :integer
attachment_mime_type :string
attachment_name :string
attachment_size :integer
attachment_uid :string
attachment_width :integer
background_color :string
category :string(255)
cloudflare_data :jsonb not null
cloudflare_uid :string
duration_in_seconds :integer
expanded_description :text
fingerprint :bigint
fingerprint_legacy :string
image_colorspace :string
image_dpi :integer
inactive :boolean default(FALSE), not null
linked_assets_uids :string default([]), is an Array
locales :string default([]), not null, is an Array
location :string
merged_from_ids :integer default([]), is an Array
meta_description :text
meta_keywords :string
meta_title :string(255)
notes :text
position :integer default(100), not null
poster_format :string
poster_mime_type :string
poster_name :string
poster_offset :integer
poster_uid :string
reference_number :string
series :string
slug :string(140)
source :string
structured_transcript_json :jsonb
sub_header :string(255)
thumbnail_url :string
title :string(255)
transcribed_at :datetime
transcript :text
transcription_state :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_jsonalone 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 viawithout_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
- POPULAR_TAGS =
Override popular tags for videos to exclude legacy 'no-index'
(DigitalAsset::POPULAR_TAGS - ['no-index']).freeze
- POPULAR_TAGS_FORM_HINT =
Popular tags form hint.
'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
Constants included from Models::Auditable
Models::Auditable::ALWAYS_IGNORED
Constants included from Schedulable
Schedulable::SIMPLE_FORM_OPTIONS
Instance Attribute Summary collapse
- #category ⇒ Object readonly
Attributes included from Models::Translatable
#do_not_compact_translation_container
Attributes inherited from DigitalAsset
#force_new_slug, #refresh_cache, #title, #url
Has many collapse
- #reviews_io_videos ⇒ ActiveRecord::Relation<ReviewsIoVideo>
- #reviews_ios ⇒ ActiveRecord::Relation<ReviewsIo>
- #uploads ⇒ ActiveRecord::Relation<Upload>
- #video_chapters ⇒ ActiveRecord::Relation<VideoChapter>
-
#video_embeddings ⇒ ActiveRecord::Relation<ContentEmbedding::VideoEmbedding>
Association to the partitioned embeddings table for AI search.
Methods included from Models::CrossLinkable
#inbound_content_links, #outbound_content_links
Methods included from Models::Embeddable
Methods inherited from DigitalAsset
#digital_asset_product_lines, #generated_images, #product_lines, #site_maps
Methods included from Models::Taggable
Belongs to collapse
- #poster_image ⇒ Image
-
#youtube_thumbnail_image ⇒ Image
Optional Image library asset used only for YouTube custom thumbnail push (overrides poster / Dragonfly / CF still).
Methods included from Models::Auditable
Class Method Summary collapse
-
.ai_search ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are ai search.
-
.ai_search_warning? ⇒ Boolean
Check if the last AI search had issues (call from controller after running scope).
-
.all_tags(exclude_tags: []) ⇒ Object
Exclude legacy 'no-index' from tag selector lists for videos.
-
.by_max_cloudflare_height ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are by max cloudflare height.
-
.by_min_cloudflare_height ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are by min cloudflare height.
-
.categories_for_select ⇒ Object
Grouped list of categories for selects Returns a hash suitable for grouped_options_for_select, e.g.
-
.clear_ai_search_warning! ⇒ Object
Clear the AI search warning.
-
.cloudflare_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are cloudflare videos.
-
.embeddable_content_types ⇒ Object
Embeddable configuration - single unified embedding like Image.
-
.expanded_description_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are expanded description presence.
-
.for_card_grid ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are for card grid.
-
.hosted_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are hosted videos.
-
.hosting_type ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are hosting type.
-
.hybrid_search ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are hybrid search.
-
.linkable_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are linkable videos.
-
.mark_videos_as_no_spoken_words(video_ids) ⇒ Object
Mark videos as having no spoken words in batch.
-
.meta_description_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are meta description presence.
-
.meta_title_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are meta title presence.
-
.public_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are public videos.
- .purge_edge_cache(index_only: true) ⇒ Object
- .ransackable_scopes(_auth_object = nil) ⇒ Object
-
.sub_header_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are sub header presence.
-
.transcript_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are transcript presence.
-
.videos_likely_no_spoken_words ⇒ Object
Find videos that likely have no spoken words (have transcript ID but no transcript data).
-
.videos_with_assemblyai_transcripts ⇒ Object
Class methods for AssemblyAI transcript management.
-
.without_transcript_columns ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are without transcript columns.
-
.youtube_id_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are youtube id presence.
Instance Method Summary collapse
-
#audio_download_ready? ⇒ Boolean
Check if audio download is ready for transcription.
- #audio_extraction_upload ⇒ Object
-
#available_translated_vtt_locales ⇒ Array<String>
Get all available translated VTT locales.
- #can_retrieve_existing_transcript? ⇒ Boolean
-
#canonical_pillar_path ⇒ Object
Locale-less canonical path nesting the video under its pillar product line, e.g.
-
#cf_animated_thumbnail_url(duration: 5, height: 480, fps: 8) ⇒ String?
Animated GIF thumbnail from Cloudflare Stream (first N seconds of video) Used for email embeds where tags are not supported.
- #cf_poster_url ⇒ Object
-
#cloudflare_all_captions ⇒ Object
Get all captions currently on Cloudflare.
-
#cloudflare_audio_download_url ⇒ Object
Get audio download URL from Cloudflare data.
- #cloudflare_captions_status ⇒ Object
-
#cloudflare_default_download_url ⇒ Object
Get default video download URL from Cloudflare data.
-
#cloudflare_download_status(download_type) ⇒ Object
Get download status for a specific type.
-
#cloudflare_duration ⇒ Object
Get Cloudflare video duration from cached data.
-
#cloudflare_file_size ⇒ Object
Get Cloudflare video file size from cached data.
-
#cloudflare_input_dimensions ⇒ Object
Get Cloudflare video input dimensions from cached data.
-
#cloudflare_mp4_url ⇒ Object
MP4 URL for SEO and fallback purposes.
-
#cloudflare_processing_percentage ⇒ Object
Get Cloudflare video processing percentage from cached data.
-
#cloudflare_ready_to_stream? ⇒ Boolean
Check if video is ready to stream from cached data.
-
#cloudflare_status ⇒ Object
Get Cloudflare video status information from cached data.
- #cloudflare_video_url ⇒ Object
- #cms_url ⇒ Object
-
#content_for_embedding(_content_type = :primary) ⇒ Object
Returns unified content for embedding - combines metadata + transcript Similar to how Image combines visual analysis + metadata + tags.
- #create_audio_extraction_upload(audio_file_path) ⇒ Object
-
#delete_from_cloudflare ⇒ Object
Delete video from Cloudflare Stream.
-
#delete_mp4_download(download_type) ⇒ Object
Delete an MP4 download from Cloudflare Stream.
- #enable_mp4 ⇒ Object
-
#enable_mp4_download(download_type) ⇒ Object
Enable specific type of video download (default or audio).
-
#enqueue_cloudflare_sync ⇒ Object
Enqueue Cloudflare synchronization after video creation.
-
#enqueue_cloudflare_sync_if_newly_cloudflare ⇒ Object
Enqueue Cloudflare synchronization when a video is updated to use Cloudflare.
-
#ensure_mp4_downloads_enabled ⇒ Object
Check if video downloads are enabled and enable them if not (legacy method - enables default).
-
#extract_poster_at_timestamp(timestamp_seconds = 5.0) ⇒ Object
Extract a poster frame from the video at the specified timestamp.
-
#fetch_cloudflare_captions ⇒ Object
Fetch captions from Cloudflare API (used by refresh_cloudflare_data).
- #fetch_cloudflare_duration ⇒ Object
-
#fetch_duration_in_seconds ⇒ Object
Probe the video for its duration.
- #ffmpeg_location ⇒ Object
- #ffprobe_location ⇒ Object
-
#frames_count ⇒ Object
Count the number of frames in the video.
-
#gallery_square_poster_url(width:, height:) ⇒ Object
Square still for PDP gallery (ImageKit pad_resize when poster_image exists; else Cloudflare Stream clip in box).
-
#has_assemblyai_transcript_id? ⇒ Boolean
AssemblyAI transcript ID methods.
-
#has_audio_download? ⇒ Boolean
Check if audio download is enabled.
-
#has_audio_extraction? ⇒ Boolean
Convenience methods for utility views (transcription_options, cloudflare_updates).
-
#has_cloudflare_caption_for_locale?(locale) ⇒ Boolean
Check if a specific language caption exists on Cloudflare.
- #has_cloudflare_captions? ⇒ Boolean
-
#has_default_download? ⇒ Boolean
Check if default video download is enabled.
-
#has_no_spoken_words? ⇒ Boolean
Check if video has been marked as having no spoken words.
- #has_polished_vtt? ⇒ Boolean
- #has_seo_data? ⇒ Boolean
- #has_structured_transcript_json? ⇒ Boolean
-
#has_translated_vtt?(locale) ⇒ Boolean
Check if translated VTT captions are available for a specific locale.
- #indexed_video? ⇒ Boolean
- #is_cloudflare? ⇒ Boolean
- #is_hosted? ⇒ Boolean
-
#mark_as_no_spoken_words! ⇒ Object
Mark video as having no spoken words (useful for videos with only music, sound effects, etc.).
-
#missing_cloudflare_captions ⇒ Object
Get list of captions available locally but not on Cloudflare.
- #original_transcript_data ⇒ Object
-
#primary_product_line ⇒ Object
The product line that supplies this video's canonical pillar URL.
-
#refresh_cloudflare_data ⇒ Object
Refresh and cache Cloudflare video data including downloads information and captions.
- #refresh_duration ⇒ Object
- #related_sources ⇒ Object
- #sentences_data ⇒ Object
- #set_duration_in_seconds ⇒ Object
- #set_poster_from_attachment ⇒ Object
- #should_skip_transcription? ⇒ Boolean
- #streamio_movie ⇒ Object
- #structured_transcript_metadata ⇒ Object
-
#structured_transcript_paragraphs ⇒ Object
Structured transcript JSON methods.
- #structured_transcript_vtt_stats ⇒ Object
- #thumbnail_url(size: '300x300#c', background: '#ffffff', width: nil, height: nil, thumbnail: true) ⇒ Object
- #to_s ⇒ Object
-
#transcript_word_count ⇒ Object
Transcript methods.
-
#translations ⇒ Object
Override translations accessor to ensure it's never nil.
- #translations=(value) ⇒ Object
-
#unmark_as_no_spoken_words! ⇒ Object
Unmark video as having no spoken words (if it was incorrectly marked).
- #update_transcript_data(result) ⇒ Object
- #vtt_original_data ⇒ Object
- #vtt_original_text_for_display ⇒ Object
- #vtt_polished_data ⇒ Object
- #vtt_polished_text_for_display ⇒ Object
-
#vtt_translated_data(locale) ⇒ Array<Hash>?
Get translated VTT data for a specific locale.
-
#vtt_translated_text_for_display(locale) ⇒ Array<Hash>
Get translated VTT data formatted for display/download.
- #website_visible? ⇒ Boolean
- #youtube_chapters_generation_error ⇒ Object
- #youtube_chapters_generation_in_progress? ⇒ Boolean
-
#youtube_chapters_generation_status ⇒ Object
YouTube chapter columns (see migration).
-
#youtube_local_caption_tracks? ⇒ Boolean
True if we have timed VTT data locally that YouTube::CaptionService can upload (English and/or translations).
Methods included from Models::CrossLinkable
#content_links_count, #linked_content, #linked_posts, #linked_publications, #linked_showcases, #linked_videos
Methods included from Models::HybridSearchable
Methods included from Models::Embeddable
#embeddable_locales, #embedding_content_hash, embedding_partition_class, #embedding_stale?, #embedding_type_name, #embedding_vector, #find_content_embedding, #find_similar, #generate_all_embeddings!, #generate_chunked_embeddings!, #generate_embedding!, #has_embedding?, #locale_for_embedding, #needs_chunking?, regenerate_all_embeddings, semantic_search
Methods included from Models::Translatable
#compact_translation_container, skip_compaction_for
Methods inherited from DigitalAsset
active, #alerts, all_locales, #all_my_items, #asset_identifier, available_banner_tags, available_og_image_tags, available_page_tags, available_page_tags_with_paths, banner_tag_for, by_category, by_item_ids, by_item_skus, by_party_ids, by_product_category_id_direct, by_product_category_id_direct_or_optional, by_product_line_id, by_product_line_id_direct, by_product_line_path, categorized, #cross_links_opportunities_to_parties, #dimensions, exclude_tags, #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
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#category ⇒ Object (readonly)
249 |
# File 'app/models/video.rb', line 249 validates :category, presence: true |
Class Method Details
.ai_search ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are ai search. Active Record Scope
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). = ContentEmbedding.(query) unless self.last_ai_search_warning = 'Could not generate AI embedding for your query. Please try a different search term.' return none end # Format vector for SQL with explicit dimension cast dimensions = .size vector_literal = "[#{.join(',')}]" # Join to the partitioned embeddings table for Videos (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)
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.(exclude_tags: []) super(exclude_tags: + ['no-index']) end |
.by_max_cloudflare_height ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are by max cloudflare height. Active Record Scope
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_height ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are by min cloudflare height. Active Record Scope
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_select ⇒ Object
Grouped list of categories for selects
Returns a hash suitable for grouped_options_for_select, e.g.
{ 'Public' => [["product", "product"], ...], 'Private' => [["unlisted", "unlisted"], ...] }
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_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are cloudflare videos. Active Record Scope
284 |
# File 'app/models/video.rb', line 284 scope :cloudflare_videos, -> { where.not(cloudflare_uid: [nil, '']) } |
.embeddable_content_types ⇒ Object
Embeddable configuration - single unified embedding like Image
1214 1215 1216 |
# File 'app/models/video.rb', line 1214 def self. [:primary] end |
.expanded_description_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are expanded description presence. Active Record Scope
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_grid ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are for card grid. Active Record Scope
280 281 282 |
# File 'app/models/video.rb', line 280 scope :for_card_grid, -> { without_transcript_columns.includes(:poster_image, :product_lines) } |
.hosted_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are hosted videos. Active Record Scope
262 |
# File 'app/models/video.rb', line 262 scope :hosted_videos, -> { where.not(attachment_uid: nil) } |
.hosting_type ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are hosting type. Active Record Scope
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_search ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are hybrid search. Active Record Scope
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_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are linkable videos. Active Record Scope
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_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are meta description presence. Active Record Scope
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_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are meta title presence. Active Record Scope
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_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are public videos. Active Record Scope
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_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are sub header presence. Active Record Scope
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_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are transcript presence. Active Record Scope
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_words ⇒ Object
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_transcripts ⇒ Object
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_columns ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are without transcript columns. Active Record Scope
268 |
# File 'app/models/video.rb', line 268 scope :without_transcript_columns, -> { select(Video.column_names - TRANSCRIPT_COLUMNS) } |
.youtube_id_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are youtube id presence. Active Record Scope
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
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_upload ⇒ Object
779 780 781 |
# File 'app/models/video.rb', line 779 def audio_extraction_upload uploads.in_category('audio_extraction').first end |
#available_translated_vtt_locales ⇒ Array<String>
Get all available translated VTT locales
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
818 819 820 |
# File 'app/models/video.rb', line 818 def can_retrieve_existing_transcript? has_assemblyai_transcript_id? && transcript.blank? end |
#canonical_pillar_path ⇒ Object
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.
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_url ⇒ Object
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_captions ⇒ Object
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_url ⇒ Object
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_status ⇒ Object
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.}" nil end |
#cloudflare_default_download_url ⇒ Object
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_duration ⇒ Object
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_size ⇒ Object
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_dimensions ⇒ Object
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_url ⇒ Object
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_percentage ⇒ Object
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
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_status ⇒ Object
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_url ⇒ Object
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_url ⇒ Object
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_type = :primary) [ , , , ].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_cloudflare ⇒ Object
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_mp4 ⇒ Object
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_sync ⇒ Object
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_cloudflare ⇒ Object
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_enabled ⇒ Object
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 ( = 5.0) return false unless is_cloudflare? || is_hosted? Rails.logger.info "Extracting poster for video #{id} at #{} seconds" service = VideoPosterExtractionService.new(self, ) service.extract_poster rescue VideoPosterExtractionService::ExtractionError => e Rails.logger.error "Failed to extract poster for video #{id}: #{e.}" false end |
#fetch_cloudflare_captions ⇒ Object
Fetch captions from Cloudflare API (used by refresh_cloudflare_data)
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.}" [] end |
#fetch_cloudflare_duration ⇒ Object
653 654 655 |
# File 'app/models/video.rb', line 653 def fetch_cloudflare_duration cloudflare_duration end |
#fetch_duration_in_seconds ⇒ Object
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? && &.path.present? cmd = "#{ffprobe_location} -i #{.path} -show_entries format=duration -v quiet -of csv=\"p=0\"" res = `#{cmd}`.strip BigDecimal(res) elsif is_cloudflare? && cloudflare_uid.present? fetch_cloudflare_duration end d&.round end |
#ffmpeg_location ⇒ Object
599 600 601 |
# File 'app/models/video.rb', line 599 def ffmpeg_location @ffmpeg_location ||= `which ffmpeg`.presence.strip || '/usr/bin/ffmpeg' end |
#ffprobe_location ⇒ Object
603 604 605 |
# File 'app/models/video.rb', line 603 def ffprobe_location @ffprobe_location ||= `which ffprobe`.presence.strip || '/usr/bin/ffprobe' end |
#frames_count ⇒ Object
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 #{.path} -v error -select_streams v:0 -show_entries stream=nb_frames -of default=nokey=1:noprint_wrappers=1" res = `#{cmd}`.strip res.to_i end |
#gallery_square_poster_url(width:, height:) ⇒ Object
Square still for PDP gallery (ImageKit pad_resize when poster_image exists; else Cloudflare Stream clip in box)
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
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
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)
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
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
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
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
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
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
783 784 785 |
# File 'app/models/video.rb', line 783 def has_seo_data? .present? && .present? && .present? end |
#has_structured_transcript_json? ⇒ 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
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
1196 1197 1198 |
# File 'app/models/video.rb', line 1196 def indexed_video? !inactive? && PRIVATE_CATEGORIES.exclude?(category) end |
#is_cloudflare? ⇒ Boolean
445 446 447 |
# File 'app/models/video.rb', line 445 def is_cloudflare? cloudflare_uid.present? end |
#is_hosted? ⇒ Boolean
449 450 451 |
# File 'app/models/video.rb', line 449 def is_hosted? .present? end |
#mark_as_no_spoken_words! ⇒ Object
Mark video as having no spoken words (useful for videos with only music, sound effects, etc.)
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_captions ⇒ Object
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_data ⇒ Object
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_image ⇒ Image
237 |
# File 'app/models/video.rb', line 237 belongs_to :poster_image, class_name: 'Image', optional: true |
#primary_product_line ⇒ Object
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_data ⇒ Object
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_duration ⇒ Object
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 |
#related_sources ⇒ Object
1204 1205 1206 1207 1208 1209 1210 1211 |
# File 'app/models/video.rb', line 1204 def Source.where( 'name LIKE ? OR name LIKE ? OR description LIKE ?', title.present? ? "%#{title}%" : nil, youtube_title.present? ? "%#{youtube_title}%" : nil, youtube_id.present? ? "%#{youtube_id}%" : nil ) end |
#reviews_io_videos ⇒ ActiveRecord::Relation<ReviewsIoVideo>
235 |
# File 'app/models/video.rb', line 235 has_many :reviews_io_videos, dependent: :destroy |
#reviews_ios ⇒ ActiveRecord::Relation<ReviewsIo>
236 |
# File 'app/models/video.rb', line 236 has_many :reviews_ios, through: :reviews_io_videos |
#sentences_data ⇒ Object
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_seconds ⇒ Object
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_attachment ⇒ Object
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 return if .blank? new_name = File.basename(.name, '.*') temp_path = Rails.root.join(Rails.application.config.x.temp_storage_path.to_s, "#{new_name}_#{Time.current.to_i}.jpg") = {} [:seek_time] = poster_offset.to_i / 1000 if poster_offset.present? streamio_movie.screenshot(temp_path.to_s, **) # Create a new Image record for the poster poster_image = Image.new( title: "#{title} - Poster", tags: ['video-poster'], source: 'video-extraction', new_image: File.open(temp_path), skip_notify: true ) if poster_image.save self.poster_image = poster_image Rails.logger.info "Created poster image #{poster_image.id} for video #{id}" else Rails.logger.error "Failed to create poster image for video #{id}: #{poster_image.errors.}" end end |
#should_skip_transcription? ⇒ Boolean
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_movie ⇒ Object
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(.path) end |
#structured_transcript_metadata ⇒ Object
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_paragraphs ⇒ Object
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_stats ⇒ Object
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_s ⇒ Object
637 638 639 |
# File 'app/models/video.rb', line 637 def to_s "#{title} [#{id}]" end |
#transcript_word_count ⇒ Object
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 |
#translations ⇒ Object
Override translations accessor to ensure it's never nil
126 127 128 |
# File 'app/models/video.rb', line 126 def translations super || {} end |
#translations=(value) ⇒ Object
130 131 132 |
# File 'app/models/video.rb', line 130 def translations=(value) super(value || {}) end |
#unmark_as_no_spoken_words! ⇒ Object
Unmark video as having no spoken words (if it was incorrectly marked)
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: #{.present?}" Rails.logger.info " - meta_description present: #{.present?}" Rails.logger.info " - expanded_description present: #{.present?}" Rails.logger.info " - duration_in_seconds: #{duration_in_seconds}" update_attributes = [] # Update transcript if provided if result[:transcript].present? Rails.logger.info "Will update transcript (length: #{result[:transcript].length})" update_attributes << :transcript end # Update AssemblyAI transcript ID if provided if result[:assemblyai_transcript_id].present? Rails.logger.info 'Will update AssemblyAI transcript ID' update_attributes << :assemblyai_transcript_id end # Update structured transcript JSON if provided (but only if not already processed) if result[:structured_transcript_json].present? && !has_structured_transcript_json? Rails.logger.info 'Will update structured transcript JSON' update_attributes << :structured_transcript_json elsif result[:structured_transcript_json].present? Rails.logger.info 'Skipping structured transcript JSON update - already processed' end # NOTE: VTT upload is handled separately and doesn't need to be tracked here # since it's stored as an Upload record # Update SEO content if provided and fields are blank if result[:seo_content].present? seo_content = result[:seo_content] if .blank? && seo_content['meta_title'].present? Rails.logger.info 'Will update meta_title' update_attributes << :meta_title end if .blank? && seo_content['meta_description'].present? Rails.logger.info 'Will update meta_description' update_attributes << :meta_description end if .blank? && seo_content['expanded_description'].present? Rails.logger.info 'Will update expanded_description' update_attributes << :expanded_description end else Rails.logger.warn 'No SEO content found in result' end # Set duration if blank if duration_in_seconds.blank? && result[:duration_in_seconds].present? Rails.logger.info 'Will update duration_in_seconds' update_attributes << :duration_in_seconds end Rails.logger.info "Final update attributes: #{update_attributes}" # Build the update hash update_hash = {} update_hash[:transcript] = result[:transcript] if result[:transcript].present? update_hash[:assemblyai_transcript_id] = result[:assemblyai_transcript_id] if result[:assemblyai_transcript_id].present? update_hash[:structured_transcript_json] = result[:structured_transcript_json] if result[:structured_transcript_json].present? && !has_structured_transcript_json? if result[:seo_content].present? seo_content = result[:seo_content] update_hash[:meta_title] = seo_content['meta_title'] if .blank? && seo_content['meta_title'].present? update_hash[:meta_description] = seo_content['meta_description'] if .blank? && seo_content['meta_description'].present? update_hash[:expanded_description] = seo_content['expanded_description'] if .blank? && seo_content['expanded_description'].present? end update_hash[:duration_in_seconds] = result[:duration_in_seconds] if duration_in_seconds.blank? && result[:duration_in_seconds].present? # Perform the update if update_hash.any? update!(update_hash) Rails.logger.info "Video updated successfully with: #{update_hash.keys}" else Rails.logger.info 'No updates needed' end end |
#uploads ⇒ ActiveRecord::Relation<Upload>
234 |
# File 'app/models/video.rb', line 234 has_many :uploads, as: :resource, dependent: :destroy |
#video_chapters ⇒ ActiveRecord::Relation<VideoChapter>
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_embeddings ⇒ ActiveRecord::Relation<ContentEmbedding::VideoEmbedding>
Association to the partitioned embeddings table for AI search
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_data ⇒ Object
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_display ⇒ Object
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_data ⇒ Object
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_display ⇒ Object
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
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
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
1200 1201 1202 |
# File 'app/models/video.rb', line 1200 def website_visible? !inactive? && PUBLIC_CATEGORIES.include?(category) end |
#youtube_chapters_generation_error ⇒ Object
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
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_status ⇒ Object
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).
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_image ⇒ Image
Optional Image library asset used only for YouTube custom thumbnail push (overrides poster / Dragonfly / CF still).
239 |
# File 'app/models/video.rb', line 239 belongs_to :youtube_thumbnail_image, class_name: 'Image', optional: true |