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

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, #touch_employee_last_seen

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?, #skip_layout_for_turbo_frame?, #stamp_impersonation_context, #tab_frame_breakout_request?, #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, #registration_lead_type, #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 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?

Instance Method Details

#cf_uploadvoid

This method returns an undefined value.

Initiates a TUS resumable upload session with Cloudflare Stream.



379
380
381
382
383
384
385
386
387
388
389
390
# File 'app/controllers/videos_controller.rb', line 379

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_updatesvoid

This method returns an undefined value.

Renders the Cloudflare updates options form.



286
287
288
# File 'app/controllers/videos_controller.rb', line 286

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

#createObject

POST /videos
POST /videos.json



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'app/controllers/videos_controller.rb', line 162

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_or_fallback(job_id, video_path(@video),
                                  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



343
344
345
346
# File 'app/controllers/videos_controller.rb', line 343

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

#destroyObject

Override the destroy method from Destroyable concern to handle Cloudflare deletion



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

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.success?
      # 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

#downloadvoid

This method returns an undefined value.

Redirects to the video's downloadable file (Cloudflare MP4 or hosted attachment).



473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
# File 'app/controllers/videos_controller.rb', line 473

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_sentencesvoid

This method returns an undefined value.

Generates and streams a plain-text sentences file from the video's
structured transcript.



497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
# File 'app/controllers/videos_controller.rb', line 497

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_vttvoid

This method returns an undefined value.

Generates and streams a VTT caption file (original/polished/translated)
for the video's structured transcript.



421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
# File 'app/controllers/videos_controller.rb', line 421

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



138
139
140
141
142
143
144
145
146
# File 'app/controllers/videos_controller.rb', line 138

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_chaptersObject

GET /videos/:id/edit_chapters — the sole chapter EDIT surface. Works for any
video (not YouTube-gated); the show and YouTube pages only display chapters
read-only and link here.



793
794
795
796
# File 'app/controllers/videos_controller.rb', line 793

def edit_chapters
  @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.



151
152
153
154
155
156
157
158
# File 'app/controllers/videos_controller.rb', line 151

def edit_youtube
  @video = Video.find(params[:id])
  authorize!(:update, @video)
  return unless @video.youtube_id.present?

  @youtube_remote_captions = YouTube::RemoteCaptionsStatus.summary(@video)
  @youtube_thumbnail_panel = YouTube::ThumbnailPanelState.summary(@video)
end

#extract_postervoid

This method returns an undefined value.

Renders the poster-frame extraction form, defaulting the timestamp from
the video's existing poster offset or the submitted/default timestamp.



521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
# File 'app/controllers/videos_controller.rb', line 521

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_cachevoid

This method returns an undefined value.

Touches the video to bust its cache.



334
335
336
337
338
339
340
# File 'app/controllers/videos_controller.rb', line 334

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_descriptionvoid

This method returns an undefined value.

Generates an AI-expanded YouTube description preview from the video's
transcript paragraphs.



875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
# File 'app/controllers/videos_controller.rb', line 875

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

#indexvoid

This method returns an undefined value.

Lists videos with filters/pagination/stats, or redirects straight to a
video when the search query is a numeric ID.



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
78
79
80
81
# File 'app/controllers/videos_controller.rb', line 50

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_uploadvoid

This method returns an undefined value.

Renders the upload-widget partial (legacy/dead path — the Faraday call
below is commented out, so response is undefined if ever reached).



396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
# File 'app/controllers/videos_controller.rb', line 396

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



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'app/controllers/videos_controller.rb', line 93

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



132
133
134
135
# File 'app/controllers/videos_controller.rb', line 132

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

#process_cloudflare_updatesvoid

This method returns an undefined value.

Queues CloudflareUpdatesWorker with the submitted download/caption
action options.



294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
# File 'app/controllers/videos_controller.rb', line 294

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_or_fallback(job_id, video_path(@video),
                              notice: 'Cloudflare updates have started. You will be redirected when complete.')
end

#process_videovoid

This method returns an undefined value.

Queues VideoTranscriptionWorker with the submitted transcription/
translation/chapter-generation options.



245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'app/controllers/videos_controller.rb', line 245

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,
    generate_chapters: params[:generate_chapters].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_or_fallback(job_id, video_path(@video),
                              notice: 'Video transcription has started. You will be redirected when complete.')
end

#queue_chapter_generationvoid

This method returns an undefined value.

Queues (guarded) chapter generation from the video's transcript.



801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
# File 'app/controllers/videos_controller.rb', line 801

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

  # Chapters are hosted-first — they feed the on-site VideoObject key-moments
  # schema and only get pushed to YouTube separately, so generation needs a
  # transcript, not a YouTube link.
  unless @video.has_structured_transcript_json? && @video.structured_transcript_paragraphs.present?
    redirect_to edit_chapters_video_path(@video), alert: 'Video has no transcript paragraphs. Transcribe the video first.'
    return
  end

  # Standard async-job UX: send the user to the job-status page; the worker
  # stores a redirect back to the chapters editor when it finishes. The
  # guarded enqueue (shared with the webhook + transcription-worker callers)
  # refuses a second run while one is queued or processing.
  job_id = VideoChapterGenerationWorker.enqueue_guarded(@video, redirect_path: edit_chapters_video_path(@video))
  if job_id.nil?
    redirect_to edit_chapters_video_path(@video), alert: 'Chapter generation is already in progress.'
    return
  end

  redirect_to job_path(job_id), notice: 'Chapter generation has started. You will be redirected when complete.'
end

#refresh_cloudflare_datavoid

This method returns an undefined value.

Refreshes the video's cached Cloudflare Stream data.



321
322
323
324
325
326
327
328
329
# File 'app/controllers/videos_controller.rb', line 321

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

  if @video.refresh_cloudflare_data.success?
    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

#showvoid

This method returns an undefined value.

Displays a video.



86
87
88
89
# File 'app/controllers/videos_controller.rb', line 86

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

#start_poster_extractionvoid

This method returns an undefined value.

Queues poster extraction at the submitted timestamp.



569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
# File 'app/controllers/videos_controller.rb', line 569

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_extractionvoid

This method returns an undefined value.

Queues SmartVideoPosterExtractionWorker to pick a poster frame via
scene-detection heuristics.



545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
# File 'app/controllers/videos_controller.rb', line 545

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_thumbnailvoid

This method returns an undefined value.

Regenerates the video's poster thumbnail.



124
125
126
127
128
# File 'app/controllers/videos_controller.rb', line 124

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

#transcription_optionsvoid

This method returns an undefined value.

Renders the transcription options form.



237
238
239
# File 'app/controllers/videos_controller.rb', line 237

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

#updateObject

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



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'app/controllers/videos_controller.rb', line 184

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?

  @video.assign_attributes(video_params)

  # Swapping the source file on a video already published to YouTube can't be
  # pushed in place (YouTube has no media-replace). Confirm first, then delete
  # the old upload so the rep re-uploads the new file fresh.
  swap_deleted = false
  if youtube_source_swapped?(@video)
    unless params[:confirm_youtube_swap].to_s == '1'
      @youtube_swap_pending = true
      return render_video_edit(return_to_youtube)
    end

    begin
      YouTube::UploadService.new.delete_remote_and_unlink(@video)
      swap_deleted = true
      push_to_youtube = false # now unlinked — it must be re-uploaded, not pushed
    rescue YouTube::ApiClient::ApiError, ArgumentError => e
      @youtube_swap_pending = true
      flash.now[:alert] = "Couldn't delete the old YouTube video (#{e.message}). The swap was not saved — try again."
      return render_video_edit(return_to_youtube)
    end
  end

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

    if swap_deleted
      redirect_to edit_youtube_video_path(@video),
                  notice: 'New video saved and the old YouTube video was deleted. Upload the new video to YouTube again following the normal procedure — it will get a new URL.'
    elsif 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
    render_video_edit(return_to_youtube)
  end
end

#update_postervoid

This method returns an undefined value.

Sets or clears the video's poster image from the picker.



592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
# File 'app/controllers/videos_controller.rb', line 592

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)



925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
# File 'app/controllers/videos_controller.rb', line 925

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.



662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
# File 'app/controllers/videos_controller.rb', line 662

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_or_fallback(
    job_id, edit_youtube_video_path(@video),
    notice: 'Extracting the frame and uploading it as the YouTube thumbnail. You can track progress here.'
  )
end

#youtube_findvoid

This method returns an undefined value.

Searches YouTube for candidate matches for an unlinked video.



962
963
964
965
966
967
968
969
970
971
# File 'app/controllers/videos_controller.rb', line 962

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

This method returns an undefined value.

Links a video to the selected YouTube video id.



976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
# File 'app/controllers/videos_controller.rb', line 976

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



910
911
912
913
914
915
916
917
918
919
920
921
922
# File 'app/controllers/videos_controller.rb', line 910

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_chaptersvoid

This method returns an undefined value.

Replaces the video's chapters with ones parsed from its YouTube description.



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

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_captionsvoid

This method returns an undefined value.

Queues YouTubeCaptionSyncWorker to push the video's structured
transcript to YouTube as captions.



747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
# File 'app/controllers/videos_controller.rb', line 747

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_chaptersvoid

This method returns an undefined value.

Appends the video's chapters to its YouTube description.



829
830
831
832
833
834
835
836
837
838
839
840
# File 'app/controllers/videos_controller.rb', line 829

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

  YouTube::ChapterService.new.push_chapters(@video)
  schedule_youtube_verify(@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.



710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
# File 'app/controllers/videos_controller.rb', line 710

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)
  schedule_youtube_verify(@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_thumbnailvoid

This method returns an undefined value.

Pushes the video's poster image to YouTube as its thumbnail.



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

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)
  schedule_youtube_verify(@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_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).



684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
# File 'app/controllers/videos_controller.rb', line 684

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



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

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_allvoid

This method returns an undefined value.

Queues YouTubeSyncWorker for every video with a YouTube ID.



901
902
903
904
905
906
# File 'app/controllers/videos_controller.rb', line 901

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_chaptersvoid

This method returns an undefined value.

Saves chapters edited directly on the chapters editor.



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

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

  if @video.update(video_chapters_params)
    redirect_to edit_chapters_video_path(@video), notice: 'Chapters saved.'
  else
    redirect_to edit_chapters_video_path(@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.



641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
# File 'app/controllers/videos_controller.rb', line 641

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_uploadvoid

This method returns an undefined value.

Queues YouTubeUploadWorker for a video that has no YouTube ID yet.



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

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