Class: VideosController

Inherits:
CrmController show all
Includes:
Controllers::Destroyable, Controllers::Showable
Defined in:
app/controllers/videos_controller.rb

Overview

Controller for managing video assets including CRUD operations, transcription,
Cloudflare integration, and video processing features.

Constant Summary collapse

DEFAULT_POSTER_TIMESTAMP =

Constants

10.0

Constants included from Controllers::ReferenceFindable

Controllers::ReferenceFindable::ID_EMBEDDED_PATTERNS

Constants included from Controllers::AnalyticsEvents

Controllers::AnalyticsEvents::MAX_QUEUED_EVENTS, Controllers::AnalyticsEvents::SESSION_KEY

Constants included from Controllers::ErrorRendering

Controllers::ErrorRendering::NON_CONTENT_PATH_PREFIXES

Constants included from Www::SeoHelper

Www::SeoHelper::AWARDS, Www::SeoHelper::CA_ADDRESS, Www::SeoHelper::CA_BUSINESS_HOURS, Www::SeoHelper::CA_CONTACT_POINT, Www::SeoHelper::CA_CURRENCIES, Www::SeoHelper::CA_DESCRIPTION, Www::SeoHelper::CA_FOUNDING_DATE, Www::SeoHelper::CA_GLOBAL_LOCATION_NUMBER, Www::SeoHelper::CA_LEGAL_NAME, Www::SeoHelper::CA_LOCAL_BUSINESS, Www::SeoHelper::CA_ONLINE_STORE, Www::SeoHelper::CA_RETURN_POLICY, Www::SeoHelper::CA_SALES_DEPARTMENT, Www::SeoHelper::CA_SERVICE_AREA, Www::SeoHelper::CA_URL, Www::SeoHelper::CA_VAT_ID, Www::SeoHelper::CA_WAREHOUSE_DEPARTMENT, Www::SeoHelper::CA_WAREHOUSE_HOURS, Www::SeoHelper::COMPANY_EMAIL, Www::SeoHelper::COMPANY_LOGO, Www::SeoHelper::COMPANY_NAME, Www::SeoHelper::COMPANY_SLOGAN, Www::SeoHelper::EXPERTISE, Www::SeoHelper::FAX_NUMBER, Www::SeoHelper::GS1_COMPANY_PREFIX, Www::SeoHelper::ISO6523_CODE, Www::SeoHelper::PAYMENT_METHODS, Www::SeoHelper::PHONE_NUMBER, Www::SeoHelper::PRIMARY_NAICS, Www::SeoHelper::REFUND_TYPE, Www::SeoHelper::RETURN_FEES, Www::SeoHelper::RETURN_METHOD, Www::SeoHelper::RETURN_POLICY_CATEGORY, Www::SeoHelper::SECONDARY_NAICS, Www::SeoHelper::SOCIAL_PROFILES, Www::SeoHelper::US_ADDRESS, Www::SeoHelper::US_BUSINESS_HOURS, Www::SeoHelper::US_CONTACT_POINT, Www::SeoHelper::US_CURRENCIES, Www::SeoHelper::US_DESCRIPTION, Www::SeoHelper::US_FOUNDING_DATE, Www::SeoHelper::US_GLOBAL_LOCATION_NUMBER, Www::SeoHelper::US_IMAGE, Www::SeoHelper::US_LEGAL_NAME, Www::SeoHelper::US_LOCAL_BUSINESS, Www::SeoHelper::US_ONLINE_STORE, Www::SeoHelper::US_RETURN_POLICY, Www::SeoHelper::US_SALES_DEPARTMENT, Www::SeoHelper::US_SERVICE_AREA, Www::SeoHelper::US_TAX_ID, Www::SeoHelper::US_URL, Www::SeoHelper::US_WAREHOUSE_DEPARTMENT, Www::SeoHelper::US_WAREHOUSE_HOURS

Constants included from IconHelper

IconHelper::CUSTOM_ICON_MAP, IconHelper::CUSTOM_SVG_DIR, IconHelper::DEFAULT_FAMILY

Instance Method Summary collapse

Methods included from Controllers::Showable

#perform_show

Methods included from Controllers::Destroyable

#perform_destroy

Methods inherited from CrmController

#access_denied, #context_id, #context_object, #crm_home_path, #current_ability, #default_url_options, #download_temp, #get_tempfile_path_for_download, #init_status_job_collector, #initialize_crm_lazy_chunks, #persist_enqueued_status_jobs, #record_not_found, #redirect_to_job_or_fallback, #render_edit_action, #set_context, #set_download_path, #stash_file_for_temp_download, #sync_admin_presence_cookie

Methods inherited from ApplicationController

#account_impersonated?, #add_to_flash, #after_sign_in_path_for, #bypass_forgery_protection?, #chat_enabled?, #cloudflare_cleared?, #default_catalog, #default_url_options, #enable_turbo_frames, #find_publication, #fix_invalid_accept_header, #init_js_utils, #is_globals_call?, #layout_by_resource, #locale_store, #redirect_to, #require_employee_for_crm, #set_base_host, #set_real_ip, #set_report_errors_for, #should_render_layout?, #stamp_impersonation_context, #warmlyyours_canada_ip?, #warmlyyours_ip?, #y

Methods included from Controllers::ReturnPathHandling

#check_for_return_path, #redirect_to_return_path_or_default

Methods included from Controllers::AnalyticsEvents

#consume_queued_analytics_events, #track_event

Methods included from Controllers::DeviceDetection

#device_detector, #is_ie?

Methods included from Controllers::SubdomainDetection

#is_crm_request?, #is_www_request?, #json_request?

Methods included from Controllers::TurboSafeRedirect

#redirect_to

Methods included from Controllers::TrackingDetection

#bot_request?, #gdpr_country?, #gdpr_country_data, #prevent_bots, #set_tracking_cookie, #track_visitor?

Methods included from Controllers::AcceleratedFileSending

#send_file_accelerated, #send_upload_accelerated

Methods included from Controllers::ErrorRendering

#excp_string, #mail_to_for_error_reporting, #render_400, #render_404, #render_406, #render_410, #render_500, #render_invalid_authenticity_token, #render_ip_spoof_error, #render_unpermitted_parameters, #safe_referer_or_fallback

Methods included from Controllers::TurnstileVerification

#load_turnstile_script_tag, #turnstile_lazy_widget, #turnstile_script_tag, #turnstile_widget, #validate_turnstile!

Methods included from Controllers::CloudflareCaching

edge_cached, #edge_cached_action?, #reset_cloudflare_cache, #set_cloudflare_cache, #skip_edge_cache!, #skip_session

Methods included from Controllers::Webpackable

#preload_webpack_fonts, #webpack_css_include, #webpack_css_url, #webpack_js_include, #wpd_is_running?

Methods included from Controllers::Localizable

#cloudflare_country_locale, #determine_request_locale, #geocoder_locale, #guest_user_locale_check, #locale_optional_www_auth_path?, #param_locale, #set_locale, #set_request_locale, #skip_localization?, #warmlyyours_ip_locale

Methods included from Controllers::Authenticable

#access_denied, #authenticate_account, #authenticate_account!, #authenticate_account_from_login_token!, #check_is_a_manager, #check_is_a_sales_manager, #check_is_an_admin, #check_is_an_employee, #check_party, #clear_mismatched_guest_user, #create_guest_user, #credentials?, #current_or_guest_user, #current_or_guest_user_id_read_only, #current_user, #devise_mapping, #fully_logged_in?, #generate_bot_id, #guest_user, #identifiable?, #init_current_user, #initialize_guest, #load_context_user, #logging_in, #resource, #resource_name, #restrict_access_for_non_employees, #scrubbed_request_path, #user_object, #warn_on_session_guest_id_leak

Methods included from ApplicationHelper

#better_number_to_currency, #check_force_logout, #check_or_cross, #check_or_times, #embedded_tab_frame_id, #error_messages, #general_disclaimer_on_product_installation_and_local_codes, #gridjs_from_html_table, #gridjs_table, #is_wy_ip, #line_break, #parent_layout, #pass_or_fail, #render_error_messages_list, #render_video_card, #resolved_auth_form_turbo_frame, #return_path_or, #safe_css_color, #set_return_path_if_present, #set_section_if_present, #tab_frame_id, #to_underscore, #track_page?, #turbo_section_wrapper, #turbo_tabs_request?, #url_on_same_domain_as_request, #widget_index_daily_focus_index_path, #working_hours?, #yes_or_no, #yes_or_no_highlighted, #yes_or_no_with_check_or_cross, #youtube_video

Methods included from UppyUploaderHelper

#file_uploader, #image_uploader, #large_file_uploader_s3, #lead_sketch_uploader, #rma_image_uploader, #rma_image_uploader_s3, #uppy_uploader, #video_uploader

Methods included from Www::ImagesHelper

#image_asset_tag, #image_asset_url

Methods included from Www::SeoHelper

#add_page_schema, #add_webpage_schema, #canada?, #company_social_links, #ensure_context_json, #json_ld_script_tag, #local_business_schema, #online_store_id, #online_store_schema, #page_main_entity, #page_main_entity_json, #render_auto_collection_page_schema, #render_collection_page_schema, #render_local_business_schema, #render_online_store_schema, #render_page_schemas, #render_page_video_schemas, #render_webpage_schema, #render_webpage_schema_with_collections, #usa?

Methods included from UrlsHelper

#catalog_breadcrumb_links, #catalog_link, #catalog_link_for_product_line, #catalog_link_for_sku, #cms_link, #delocalized_path, #path_to_sales_product_sku, #path_to_sales_product_sku_for_product_line, #path_to_sales_product_sku_for_product_line_slug, #product_line_from_catalog_link, #protocol_neutral_url, #sanitize_external_url, #valid_external_url?

Methods included from IconHelper

#account_nav_icon, #fa_icon, #star_rating_html

Instance Method Details

#cf_uploadObject



324
325
326
327
328
329
330
331
332
333
334
335
# File 'app/controllers/videos_controller.rb', line 324

def cf_upload
  result = CloudflareStreamApi.instance.initiate_tus_upload(
    upload_length:   request.headers['Upload-Length'],
    upload_metadata: request.headers['Upload-Metadata']
  )

  if result[:success]
    render plain: '', status: :created, location: result[:location]
  else
    render json: { error: result[:error] }, status: :internal_server_error
  end
end

#cloudflare_updatesObject



245
246
247
# File 'app/controllers/videos_controller.rb', line 245

def cloudflare_updates
  @video = Video.find(params[:id])
end

#createObject

POST /videos
POST /videos.json



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'app/controllers/videos_controller.rb', line 152

def create
  authorize!(:create, Video)
  @video = Video.new(video_params)
  if @video.save
    # If this is a Cloudflare video, enqueue the monitoring worker and redirect to job status
    if @video.is_cloudflare?
      # CloudflareVideoMonitorWorker will monitor processing and automatically create downloads
      job_id = CloudflareVideoMonitorWorker.perform_async(@video.id, { redirect_path: video_path(@video) })

      # Redirect to job progress page so user can see processing status
      redirect_to job_path(job_id), notice: 'Video was successfully created. Monitoring video processing...'
    else
      redirect_to @video, notice: 'Video was successfully created.'
    end
  else
    render action: 'new', status: :unprocessable_content
  end
end

#delete_confirmationObject

Show delete confirmation page



291
292
293
294
# File 'app/controllers/videos_controller.rb', line 291

def delete_confirmation
  @video = Video.find(params[:id])
  authorize!(:destroy, @video)
end

#destroyObject

Override the destroy method from Destroyable concern to handle Cloudflare deletion



297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
# File 'app/controllers/videos_controller.rb', line 297

def destroy
  @video = Video.find(params[:id])
  authorize!(:destroy, @video)

  # Check if user wants to delete from Cloudflare as well
  delete_from_cloudflare = params[:delete_from_cloudflare] == 'true'

  if @video.is_cloudflare? && delete_from_cloudflare
    # Delete from Cloudflare first
    if @video.delete_from_cloudflare
      # Then delete the local record
      @video.destroy
      flash[:info] = "Video '#{@video.title}' was deleted successfully from both the database and Cloudflare Stream."
    else
      flash[:error] = 'Failed to delete video from Cloudflare Stream. The video record was not deleted.'
      redirect_to @video
      return
    end
  else
    # Just delete the local record
    @video.destroy
    flash[:info] = "Video '#{@video.title}' was deleted successfully from the database."
  end

  redirect_to videos_path
end

#downloadObject



407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
# File 'app/controllers/videos_controller.rb', line 407

def download
  @video = Video.find(params[:id])
  authorize!(:read, @video)

  download_url = nil

  if @video.is_cloudflare?
    @video.ensure_mp4_downloads_enabled
    download_url = @video.cloudflare_mp4_url
  elsif @video.is_hosted? && @video.attachment&.url.present?
    download_url = @video.attachment.url
  end

  if download_url.present?
    redirect_to download_url, allow_other_host: true
  else
    redirect_to @video, alert: 'Download is not available for this video.'
  end
end

#download_sentencesObject



427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
# File 'app/controllers/videos_controller.rb', line 427

def download_sentences
  @video = Video.find(params[:id])
  authorize!(:read, @video)

  unless @video.has_structured_transcript_json? && @video.sentences_data.present?
    redirect_to @video, alert: 'No sentences data available for download'
    return
  end

  # Generate sentences content
  sentences_content = VttService.generate_sentences_content(@video.sentences_data)

  # Set response headers for file download
  filename = VttService.generate_sentences_filename(@video.title)
  send_data sentences_content,
            filename: filename,
            type: 'text/plain',
            disposition: 'attachment'
end

#download_vttObject



358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
# File 'app/controllers/videos_controller.rb', line 358

def download_vtt
  @video = Video.find(params[:id])
  authorize!(:read, @video)

  unless @video.has_structured_transcript_json?
    redirect_to @video, alert: 'No structured transcript data available for VTT generation'
    return
  end

  vtt_type = params[:type] || 'polished'
  locale = params[:locale] # For translated captions

  case vtt_type
  when 'original'
    vtt_data = @video.vtt_original_text_for_display
    filename = VttService.generate_vtt_filename(@video.title, 'original')
  when 'polished'
    vtt_data = @video.vtt_polished_text_for_display
    filename = VttService.generate_vtt_filename(@video.title, 'polished')
  when 'translated'
    # Handle translated VTT captions
    unless locale.present? && @video.has_translated_vtt?(locale)
      redirect_to @video, alert: "No translated captions available for locale: #{locale}"
      return
    end

    vtt_data = @video.vtt_translated_text_for_display(locale)
    locale_info = VideoProcessing::VideoTranslationService::SUPPORTED_LOCALES[locale]
    locale_suffix = locale_info ? locale_info[:name].parameterize : locale.to_s
    filename = VttService.generate_vtt_filename(@video.title, "polished-#{locale_suffix}")
  else
    redirect_to @video, alert: 'Invalid VTT type specified'
    return
  end

  if vtt_data.present?
    # Generate VTT content from the specified data
    vtt_content = VttService.generate_vtt_content(vtt_data)

    # Set response headers for file download
    send_data vtt_content,
              filename: filename,
              type: 'text/vtt',
              disposition: 'attachment'
  else
    redirect_to @video, alert: "No #{vtt_type} VTT data available"
  end
end

#editObject

GET /videos/1/edit



128
129
130
131
132
133
134
135
136
# File 'app/controllers/videos_controller.rb', line 128

def edit
  headers['Access-Control-Allow-Origin'] = '*'
  headers['Access-Control-Allow-Headers'] = '*'
  headers['Access-Control-Request-Method'] = '*'
  headers['Access-Control-Max-Age'] = '1728000'
  headers['Access-Control-Expose-Headers'] = '*'
  @video = Video.find(params[:id])
  authorize!(:update, Video)
end

#edit_youtubeObject

GET /videos/1/edit_youtube
Dedicated YouTube management screen — metadata form + status + actions
(sync, captions, thumbnail, chapters, upload) all on one page.



141
142
143
144
145
146
147
148
# File 'app/controllers/videos_controller.rb', line 141

def edit_youtube
  @video = Video.find(params[:id])
  authorize!(:update, @video)
  if @video.youtube_id.present?
    @youtube_remote_captions = YouTube::RemoteCaptionsStatus.summary(@video)
    @youtube_thumbnail_panel = YouTube::ThumbnailPanelState.summary(@video)
  end
end

#extract_posterObject



447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
# File 'app/controllers/videos_controller.rb', line 447

def extract_poster
  @video = Video.friendly.find(params[:id])
  authorize!(:update, @video)

  # Set default timestamp (seconds). poster_offset is stored in milliseconds.
  @timestamp_seconds = if @video.poster_offset.present?
                         (@video.poster_offset.to_f / 1000.0)
                       elsif params[:timestamp_seconds].present?
                         parse_timestamp_seconds(params[:timestamp_seconds])
                       else
                         DEFAULT_POSTER_TIMESTAMP
                       end

  # Set return path for image picker
  @return_path = extract_poster_video_path(@video)

  # Set up image ransack for the picker
  set_image_ransack
end

#flush_cacheObject



282
283
284
285
286
287
288
# File 'app/controllers/videos_controller.rb', line 282

def flush_cache
  authorize!(:update, Video)
  @video = Video.find(params[:id])
  @video.touch
  @video.purge_edge_cache
  redirect_to(@video, notice: 'Cache purge has been queued, this can take a couple minutes')
end

#generate_expanded_descriptionObject



764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
# File 'app/controllers/videos_controller.rb', line 764

def generate_expanded_description
  @video = Video.find(params[:id])
  authorize!(:update, @video)

  unless @video.has_structured_transcript_json? && @video.structured_transcript_paragraphs.present?
    redirect_to @video, alert: 'Video has no transcript paragraphs. Transcribe the video first.'
    return
  end

  service = YouTube::DescriptionService.new
  description = service.generate_description(@video)
  if description.blank?
    redirect_to @video, alert: service.preview_failure_message.presence || 'Could not generate a description.'
    return
  end

  Mobility.with_locale(:en) { @video.update!(expanded_description: description) }
  redirect_to @video, notice: "Generated and saved a fresh expanded description from the transcript (en locale)."
rescue StandardError => e
  Rails.logger.error("[VideosController#generate_expanded_description] #{e.class}: #{e.message}")
  redirect_to @video, alert: "Description generation failed: #{e.message.truncate(200)}"
end

#indexObject



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'app/controllers/videos_controller.rb', line 46

def index
  # Direct ID lookup: redirect straight to the video if the query is a numeric ID
  search_query = params[:search_query].to_s.strip
  if search_query.match?(/\A\d+\z/)
    video = Video.find_by(id: search_query.to_i)
    return redirect_to video_path(video) if video
  end

  params[:q] ||= {}
  setup_default_filters
  setup_search_and_pagination
  calculate_statistics
  calculate_percentages

  # Support showcase/party/opportunity selection mode (similar to images controller)
  @selectable = params[:showcase_id].present? || params[:party_id].present? || params[:opportunity_id].present?
  @linked_video_ids = if params[:showcase_id].present?
                        ShowcaseDigitalAsset.where(showcase_id: params[:showcase_id]).pluck(:digital_asset_id)
                      elsif params[:party_id].present?
                        Party.find(params[:party_id]).digital_asset_ids
                      elsif params[:opportunity_id].present?
                        Opportunity.find(params[:opportunity_id]).digital_asset_ids
                      else
                        []
                      end

  respond_to do |format|
    format.html
    format.turbo_stream { render_turbo_stream_response }
    format.json { render_json_response }
  end
end

#initiate_uploadObject



337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'app/controllers/videos_controller.rb', line 337

def initiate_upload
  # response = Faraday.post('https://api.cloudflare.com/client/v4/accounts/79b7f58cf035093b5ad11747df30369a/stream') do |req|
  #   req.headers['Authorization'] = "Bearer #{CF_STREAM_AND_IMAGE_TOKEN}"
  #   req.headers['Tus-Resumable'] = '1.0.0'
  #   req.headers['Upload-Length'] = '0' # This might need to be dynamic or set to a max limit
  # end
  # puts response.body
  #
  # if response.success?
  # render json: { uploadUrl: response.headers['Location'] }, status: :ok
  # respond_to do |format|
  #   format.turbo_stream do
  #     render turbo_stream: turbo_stream.replace("video_upload", partial: "videos/upload", locals: { upload_url: upload_url })
  #   end
  # end
  render partial: 'videos/upload', locals: { upload_url: 'https://api.cloudflare.com/client/v4/accounts/79b7f58cf035093b5ad11747df30369a/stream', res: response.body }
  # else
  #   render partial: "videos/upload_failed", locals: { res: response.body }
  # end
end

#lookupObject

GET /videos/lookup
Returns JSON for tom-select async loading



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'app/controllers/videos_controller.rb', line 86

def lookup
  video_id = params[:video_id].presence
  term = params[:q].to_s.strip
  page = (params[:page] || 1).to_i
  per_page = (params[:per_page] || 20).to_i

  if video_id
    # Direct ID lookup (for pre-populating selected value)
    @results = Video.where(id: video_id)
  else
    # Search by title
    @results = Video.active.cloudflare_videos.order(:title)
    if term.present?
      ilike = "%#{ActiveRecord::Base.sanitize_sql_like(term)}%"
      @results = @results.where('LOWER(title) LIKE LOWER(?)', ilike)
    end
  end

  total_entries = @results.count
  @results = @results.offset((page - 1) * per_page).limit(per_page)

  results_array = @results.map do |v|
    { id: v.id, text: v.title }
  end

  render json: { results: results_array, total: total_entries }
end

#newObject

GET /videos/new
GET /videos/new.json



122
123
124
125
# File 'app/controllers/videos_controller.rb', line 122

def new
  authorize!(:create, Video)
  @video = Video.new(params[:video].present? ? video_params : {})
end

#process_cloudflare_updatesObject



249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
# File 'app/controllers/videos_controller.rb', line 249

def process_cloudflare_updates
  @video = Video.find(params[:id])

  # Extract action options from params (enable/disable/skip)
  options = {
    default_download_action: params[:default_download_action] || 'skip',
    audio_download_action: params[:audio_download_action] || 'skip',
    vtt_captions_en_action: params[:vtt_captions_en_action] || 'skip'
  }

  # Add translated caption actions for each supported locale
  VideoProcessing::VideoTranslationService::SUPPORTED_LOCALES.keys.each do |locale|
    param_key = "vtt_captions_#{locale.underscore}_action"
    options[param_key.to_sym] = params[param_key] || 'skip'
  end

  # Start the Cloudflare updates worker
  job_id = CloudflareUpdatesWorker.perform_async(@video.id, options.merge(redirect_path: video_path(@video)))

  # Redirect to job progress page
  redirect_to job_path(job_id), notice: 'Cloudflare updates have started. You will be redirected when complete.'
end

#process_videoObject



209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'app/controllers/videos_controller.rb', line 209

def process_video
  @video = Video.find(params[:id])

  # Debug logging to see what parameters are received
  Rails.logger.debug('process_video called', video_id: @video.id)

  # Extract options from params
  options = {
    extract_audio: params[:extract_audio].to_b,
    submit_transcription: params[:submit_transcription].to_b,
    retrieve_transcript: params[:retrieve_transcript].to_b,
    polish_transcript: params[:polish_transcript].to_b,
    translate_transcript: params[:translate_transcript].to_b,
    translate_to_french_ca: params[:translate_to_french_ca].to_b,
    translate_to_spanish_mx: params[:translate_to_spanish_mx].to_b,
    translate_to_polish: params[:translate_to_polish].to_b,
    upload_vtt_to_cloudflare: params[:upload_vtt_to_cloudflare].to_b,
    generate_seo: params[:generate_seo].to_b
  }

  Rails.logger.debug('process_video options', video_id: @video.id, options: options.select { |_, v| v })

  # Handle speakers_expected - only include if a specific number is selected
  speakers_expected = params[:speakers_expected].to_i
  options[:speakers_expected] = speakers_expected if speakers_expected > 0

  # Pass the current employee ID for notification when transcription completes
  options[:requested_by_id] = current_user.id if current_user.present?

  # Start the transcription worker with options and redirect path
  job_id = VideoTranscriptionWorker.perform_async(@video.id, video_path(@video), options)

  # Redirect to job progress page
  redirect_to job_path(job_id), notice: 'Video transcription has started. You will be redirected when complete.'
end

#refresh_cloudflare_dataObject



272
273
274
275
276
277
278
279
280
# File 'app/controllers/videos_controller.rb', line 272

def refresh_cloudflare_data
  @video = Video.find(params[:id])

  if @video.refresh_cloudflare_data
    redirect_to video_path(@video, anchor: 'cloudflare'), notice: 'Cloudflare data refreshed successfully.'
  else
    redirect_to video_path(@video, anchor: 'cloudflare'), alert: 'Failed to refresh Cloudflare data.'
  end
end

#showObject



79
80
81
82
# File 'app/controllers/videos_controller.rb', line 79

def show
  @video = Video.find(params[:id])
  super
end

#start_poster_extractionObject



488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
# File 'app/controllers/videos_controller.rb', line 488

def start_poster_extraction
  @video = Video.friendly.find(params[:id])
  authorize!(:update, @video)

  # Accept timestamp from multiple possible param shapes (unscoped or form scope)
  raw_ts = params[:timestamp_seconds]
  raw_ts ||= params.dig(:video, :timestamp_seconds)
  raw_ts ||= params.dig(:model, :timestamp_seconds)
  timestamp_seconds = parse_timestamp_seconds(raw_ts)

  job_id = VideoPosterService.extract_poster_from_timestamp(@video, timestamp_seconds, video_path(@video))

  if job_id
    redirect_to job_path(job_id), notice: 'Poster extraction has been queued. You can track the progress below.'
  else
    flash[:error] = 'Please provide a valid timestamp (seconds).'
    redirect_to extract_poster_video_path(@video)
  end
end

#start_smart_poster_extractionObject



467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
# File 'app/controllers/videos_controller.rb', line 467

def start_smart_poster_extraction
  @video = Video.friendly.find(params[:id])
  authorize!(:update, @video)

  min_start = (params[:min_start] || 5.0).to_f
  threshold = (params[:threshold] || 0.40).to_f
  fallback  = (params[:fallback] || 10.0).to_f

  job_id = SmartVideoPosterExtractionWorker.perform_async(
    @video.id,
    { 'min_start' => min_start, 'threshold' => threshold, 'fallback' => fallback },
    video_path(@video)
  )

  if job_id
    redirect_to job_path(job_id), notice: 'Smart poster extraction queued. You will be redirected when complete.'
  else
    redirect_to extract_poster_video_path(@video), alert: 'Failed to queue smart poster extraction.'
  end
end

#sync_thumbnailObject



114
115
116
117
118
# File 'app/controllers/videos_controller.rb', line 114

def sync_thumbnail
  @video = Video.find(params[:id])
  @video.set_poster
  redirect_to @video, notice: 'Video thumbnail synchronized'
end

#transcription_optionsObject



205
206
207
# File 'app/controllers/videos_controller.rb', line 205

def transcription_options
  @video = Video.find(params[:id])
end

#updateObject

PUT /videos/1
PUT /videos/1.json



173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'app/controllers/videos_controller.rb', line 173

def update
  @video = Video.find(params[:id])
  authorize!(:update, @video)

  return_to_youtube = params[:return_to].to_s == 'youtube'
  push_to_youtube = return_to_youtube && params[:push_to_youtube].to_s == '1' && @video.youtube_id.present?

  if @video.update(video_params)
    @video.purge_edge_cache
    Video.purge_edge_cache(index_only: true) if @video.indexed_video?

    if push_to_youtube
      push_summary = push_everything_to_youtube(@video)
      redirect_to edit_youtube_video_path(@video), notice: "Saved. #{push_summary}"
    elsif return_to_youtube
      redirect_to edit_youtube_video_path(@video), notice: 'YouTube options were saved.'
    else
      redirect_to @video, notice: 'Video was successfully updated.'
    end
  else
    if return_to_youtube
      if @video.youtube_id.present?
        @youtube_remote_captions = YouTube::RemoteCaptionsStatus.summary(@video)
        @youtube_thumbnail_panel = YouTube::ThumbnailPanelState.summary(@video)
      end
      render action: 'edit_youtube', status: :unprocessable_content
    else
      render action: 'edit', status: :unprocessable_content
    end
  end
end

#update_posterObject



508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
# File 'app/controllers/videos_controller.rb', line 508

def update_poster
  @video = Video.friendly.find(params[:id])
  authorize!(:update, @video)

  if params[:poster_image_id].present?
    # User selected an existing image
    image = Image.find(params[:poster_image_id])
    @video.update!(poster_image: image)
    @video.purge_edge_cache
    flash[:notice] = "Poster image '#{image.title}' selected successfully"
  elsif params[:poster_image_id].blank?
    # User cleared the poster image
    @video.update!(poster_image: nil)
    @video.purge_edge_cache
    flash[:notice] = 'Poster image removed successfully'
  end

  redirect_to extract_poster_video_path(@video)
end

POST /videos/youtube_bulk_link — params[:selected_pairs] = ["123:abcDef", ...] (video_id:youtube_id)



811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
# File 'app/controllers/videos_controller.rb', line 811

def youtube_bulk_link
  authorize!(:update, Video)

  pairs = Array(params[:selected_pairs]).map(&:to_s).compact_blank
  if pairs.empty?
    redirect_to youtube_match_candidates_videos_path, alert: 'Select at least one suggested link.'
    return
  end

  service = YouTube::AutoLinkService.new
  linked = 0
  errors = []

  pairs.each do |raw|
    video_id_s, youtube_id = raw.split(':', 2)
    video_id = video_id_s.to_i
    if video_id.zero? || youtube_id.blank?
      errors << "Invalid pair: #{raw}"
      next
    end

    service.safe_link!(video_id, youtube_id)
    linked += 1
  rescue ArgumentError => e
    errors << "Video #{video_id}: #{e.message}"
  rescue StandardError => e
    errors << "Video #{video_id}: #{e.message}"
  end

  flash[:notice] = "Linked #{linked} video#{'s' if linked != 1} to YouTube." if linked.positive?
  flash[:alert] = errors.join(' ') if errors.any?
  redirect_to youtube_match_candidates_videos_path
end

#youtube_extract_thumbnailObject

Extract a frame from the video at the user-picked timestamp and assign
it as the YouTube thumbnail (sets youtube_thumbnail_image_id). Reuses
the same worker the poster-extraction flow uses — just points it at the
YouTube FK instead of poster_image_id.



579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
# File 'app/controllers/videos_controller.rb', line 579

def youtube_extract_thumbnail
  @video = Video.find(params[:id])
  authorize!(:update, @video)

  raw_ts = params[:timestamp_seconds]
  raw_ts ||= params.dig(:video, :timestamp_seconds)
  timestamp_seconds = parse_timestamp_seconds(raw_ts)

  if timestamp_seconds.nil? || timestamp_seconds.negative? || (@video.duration_in_seconds.present? && timestamp_seconds > @video.duration_in_seconds)
    redirect_to edit_youtube_video_path(@video), alert: 'Please provide a valid timestamp (seconds).'
    return
  end

  job_id = VideoPosterExtractionWorker.perform_async(@video.id, timestamp_seconds, edit_youtube_video_path(@video), 'youtube_thumbnail_image_id')
  redirect_to job_path(job_id), notice: 'Extracting the frame and uploading it as the YouTube thumbnail. You can track progress here.'
end

#youtube_findObject



845
846
847
848
849
850
851
852
853
854
# File 'app/controllers/videos_controller.rb', line 845

def youtube_find
  @video = Video.find(params[:id])
  authorize!(:update, @video)

  service = YouTube::AutoLinkService.new
  @candidates = service.search_for_video(@video)
rescue YouTube::OauthService::TokenRefreshError, YouTube::ApiClient::ApiError => e
  @candidates = []
  flash.now[:alert] = "YouTube API error: #{e.message}"
end


856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
# File 'app/controllers/videos_controller.rb', line 856

def youtube_link
  @video = Video.find(params[:id])
  authorize!(:update, @video)

  youtube_id = params[:youtube_id]
  if youtube_id.blank?
    redirect_to @video, alert: 'No YouTube video selected.'
    return
  end

  YouTube::AutoLinkService.new.link!(@video.id, youtube_id)
  redirect_to @video, notice: "Linked to YouTube video #{youtube_id}. Metadata sync queued."
rescue StandardError => e
  redirect_to @video, alert: "Failed to link: #{e.message}"
end

#youtube_match_candidatesObject

GET /videos/youtube_match_candidates
Lists suggested pairings between unlinked CRM videos and YouTube channel uploads (title + duration heuristics).



796
797
798
799
800
801
802
803
804
805
806
807
808
# File 'app/controllers/videos_controller.rb', line 796

def youtube_match_candidates
  authorize!(:update, Video)

  @videos_by_id = {}
  service = YouTube::AutoLinkService.new
  @matches = service.discover_matches(local_scope: Video.active)
  ids = @matches.pluck(:local_video_id).uniq
  @videos_by_id = Video.includes(:poster_image).where(id: ids).index_by(&:id) if ids.any?
rescue YouTube::OauthService::TokenRefreshError, YouTube::ApiClient::ApiError => e
  @matches = []
  @videos_by_id = {}
  flash.now[:alert] = "YouTube API error: #{e.message}"
end

#youtube_pull_chaptersObject



741
742
743
744
745
746
747
748
749
750
751
# File 'app/controllers/videos_controller.rb', line 741

def youtube_pull_chapters
  @video = Video.find(params[:id])
  authorize!(:update, @video)

  YouTube::ChapterService.new.pull_chapters_from_description(@video)
  redirect_to @video, notice: 'Chapters were replaced with the parsed YouTube description.'
rescue ArgumentError => e
  redirect_to @video, alert: e.message.to_s
rescue YouTube::OauthService::TokenRefreshError, YouTube::ApiClient::ApiError => e
  redirect_to @video, alert: "YouTube API error: #{e.message}"
end

#youtube_push_captionsObject



653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
# File 'app/controllers/videos_controller.rb', line 653

def youtube_push_captions
  @video = Video.find(params[:id])
  authorize!(:update, @video)

  if @video.youtube_id.blank?
    redirect_to @video, alert: 'Video has no YouTube ID.'
    return
  end

  unless @video.has_structured_transcript_json?
    redirect_to @video, alert: 'Video has no transcript data. Transcribe the video first.'
    return
  end

  YouTubeCaptionSyncWorker.perform_async(@video.id)
  redirect_to @video, notice: 'Caption push to YouTube queued.'
end

#youtube_push_chaptersObject



729
730
731
732
733
734
735
736
737
738
739
# File 'app/controllers/videos_controller.rb', line 729

def youtube_push_chapters
  @video = Video.find(params[:id])
  authorize!(:update, @video)

  YouTube::ChapterService.new.push_chapters(@video)
  redirect_to @video, notice: 'Chapters were appended to the YouTube video description.'
rescue ArgumentError => e
  redirect_to @video, alert: e.message.to_s
rescue YouTube::OauthService::TokenRefreshError, YouTube::ApiClient::ApiError => e
  redirect_to @video, alert: "YouTube API error: #{e.message}"
end

#youtube_push_metadataObject

Push Heatwave's current title / description / AI disclosure to an
existing YouTube video. Runs inline (single API call, <1s) so the user
gets immediate feedback instead of a queued worker — keeps the
edit-and-push loop tight.



624
625
626
627
628
629
630
631
632
633
634
635
636
637
# File 'app/controllers/videos_controller.rb', line 624

def 
  @video = Video.find(params[:id])
  authorize!(:update, @video)

  if @video.youtube_id.blank?
    redirect_to edit_youtube_video_path(@video), alert: "Can't push — this video isn't linked to a YouTube video yet."
    return
  end

  YouTube::UploadService.new.(@video)
  redirect_to edit_youtube_video_path(@video), notice: 'Title, description and AI disclosure pushed to YouTube.'
rescue YouTube::ApiClient::ApiError, ArgumentError => e
  redirect_to edit_youtube_video_path(@video), alert: "Push failed: #{e.message}"
end

#youtube_push_thumbnailObject



671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
# File 'app/controllers/videos_controller.rb', line 671

def youtube_push_thumbnail
  @video = Video.find(params[:id])
  authorize!(:update, @video)

  if @video.youtube_id.blank?
    redirect_to @video, alert: 'Video has no YouTube ID.'
    return
  end

  unless @video.poster_image.present? || @video.poster_uid.present? || @video.is_cloudflare?
    redirect_to @video, alert: 'Video has no poster image to push.'
    return
  end

  service = YouTube::ThumbnailService.new
  service.push_thumbnail(@video)
  redirect_to @video, notice: 'Thumbnail pushed to YouTube successfully.'
rescue YouTube::ApiClient::ApiError => e
  redirect_to @video, alert: "Thumbnail push failed: #{e.message}"
end

#youtube_queue_chapter_generationObject



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
# File 'app/controllers/videos_controller.rb', line 692

def youtube_queue_chapter_generation
  @video = Video.find(params[:id])
  authorize!(:update, @video)

  if @video.youtube_id.blank?
    redirect_to @video, alert: 'Video has no YouTube ID.'
    return
  end

  unless @video.has_structured_transcript_json? && @video.structured_transcript_paragraphs.present?
    redirect_to @video, alert: 'Video has no transcript paragraphs. Transcribe the video first.'
    return
  end

  skip_enqueue = false
  @video.with_lock do
    @video.reload
    if @video.youtube_chapters_generation_in_progress?
      skip_enqueue = true
    else
      @video.update!(
        youtube_chapters_generation_status: 'queued',
        youtube_chapters_generation_error: nil
      )
    end
  end

  if skip_enqueue
    redirect_to @video, alert: 'Chapter generation is already in progress.'
    return
  end

  YouTubeChapterGenerationWorker.perform_async(@video.id)
  redirect_to @video,
              notice: 'Chapter generation is running in the background. Refresh this page in a moment to review the draft, then push to YouTube when ready.'
end

#youtube_set_visibilityObject

Update only the privacy status of an existing YouTube video. Used by
the visibility toggle on the Overview tab. Inline (single API call).



598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
# File 'app/controllers/videos_controller.rb', line 598

def youtube_set_visibility
  @video = Video.find(params[:id])
  authorize!(:update, @video)

  if @video.youtube_id.blank?
    redirect_to edit_youtube_video_path(@video), alert: "Can't set visibility — this video isn't on YouTube yet."
    return
  end

  new_status = params[:privacy_status].to_s
  unless %w[private unlisted public].include?(new_status)
    redirect_to edit_youtube_video_path(@video), alert: 'Invalid privacy status.'
    return
  end

  YouTube::ApiClient.new.update_video(@video.youtube_id, status_attrs: { privacy_status: new_status })
  @video.update_columns(youtube_privacy_status: new_status, youtube_synced_at: Time.current)
  redirect_to edit_youtube_video_path(@video), notice: "Visibility set to #{new_status}."
rescue YouTube::ApiClient::ApiError => e
  redirect_to edit_youtube_video_path(@video), alert: "Failed: #{e.message}"
end

#youtube_syncObject

--- YouTube API actions ---



530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
# File 'app/controllers/videos_controller.rb', line 530

def youtube_sync
  @video = Video.find(params[:id])
  authorize!(:update, @video)

  if @video.youtube_id.blank?
    redirect_to edit_youtube_video_path(@video), alert: 'Video has no YouTube ID to sync.'
    return
  end

  YouTubeSyncWorker.perform_async(@video.id)

  # Pull chapters from the live YouTube description in the same click —
  # the chapter service fetches the snippet directly from YouTube, so it
  # doesn't need to wait on the metadata worker.
  chapter_note = ''
  begin
    YouTube::ChapterService.new.pull_chapters_from_description(@video)
    chapter_note = ' Chapters pulled from the YouTube description.'
  rescue ArgumentError, YouTube::OauthService::TokenRefreshError, YouTube::ApiClient::ApiError => e
    Rails.logger.warn("[YouTube] chapter pull on sync failed for video #{@video.id}: #{e.message}")
  end

  redirect_to edit_youtube_video_path(@video), notice: "Sync from YouTube queued. Metadata will be updated shortly.#{chapter_note}"
end

#youtube_sync_allObject



787
788
789
790
791
792
# File 'app/controllers/videos_controller.rb', line 787

def youtube_sync_all
  authorize!(:manage, Video)

  YouTubeSyncWorker.perform_async
  redirect_to videos_path, notice: 'YouTube sync queued for all videos with YouTube IDs.'
end

#youtube_update_chaptersObject



753
754
755
756
757
758
759
760
761
762
# File 'app/controllers/videos_controller.rb', line 753

def youtube_update_chapters
  @video = Video.find(params[:id])
  authorize!(:update, @video)

  if @video.update(video_chapters_params)
    redirect_to @video, notice: 'Chapters saved.'
  else
    redirect_to @video, alert: "Could not save chapters: #{@video.errors.full_messages.to_sentence}"
  end
end

#youtube_update_thumbnail_imageObject

Assign / clear the YouTube thumbnail image FK from the library picker on
the Thumbnail tab. Separate from the main metadata form so the Thumbnail
tab can save without piggybacking on the Details form.



558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
# File 'app/controllers/videos_controller.rb', line 558

def youtube_update_thumbnail_image
  @video = Video.find(params[:id])
  authorize!(:update, @video)

  image_id = params[:youtube_thumbnail_image_id].presence
  if image_id
    image = Image.find(image_id)
    @video.update!(youtube_thumbnail_image_id: image.id)
    flash[:notice] = "Thumbnail '#{image.title}' selected. Use Push to YouTube → Push thumbnail to send it."
  else
    @video.update!(youtube_thumbnail_image_id: nil)
    flash[:notice] = 'Thumbnail cleared.'
  end

  redirect_to edit_youtube_video_path(@video)
end

#youtube_uploadObject



639
640
641
642
643
644
645
646
647
648
649
650
651
# File 'app/controllers/videos_controller.rb', line 639

def youtube_upload
  @video = Video.find(params[:id])
  authorize!(:update, @video)

  if @video.youtube_id.present?
    redirect_to @video, alert: 'Video already has a YouTube ID. Cannot upload again.'
    return
  end

  privacy_status = params[:privacy_status].presence || 'private'
  YouTubeUploadWorker.perform_async(@video.id, privacy_status)
  redirect_to @video, notice: "YouTube upload queued (#{privacy_status}). This may take several minutes."
end