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, #initialize_crm_lazy_chunks, #record_not_found, #redirect_to_job_or_fallback, #render_edit_action, #set_context, #set_download_path, #stash_file_for_temp_download

Methods inherited from ApplicationController

#account_impersonated?, #add_to_flash, #append_token, #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::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, #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_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!, #authenticate_account_from_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, #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, #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



295
296
297
298
299
300
301
302
303
304
305
306
# File 'app/controllers/videos_controller.rb', line 295

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



216
217
218
# File 'app/controllers/videos_controller.rb', line 216

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

#createObject

POST /videos
POST /videos.json



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'app/controllers/videos_controller.rb', line 142

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

#delete_confirmationObject

Show delete confirmation page



262
263
264
265
# File 'app/controllers/videos_controller.rb', line 262

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

#destroyObject

Override the destroy method from Destroyable concern to handle Cloudflare deletion



268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
# File 'app/controllers/videos_controller.rb', line 268

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



378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
# File 'app/controllers/videos_controller.rb', line 378

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



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

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



329
330
331
332
333
334
335
336
337
338
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
# File 'app/controllers/videos_controller.rb', line 329

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



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

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

#extract_posterObject



418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
# File 'app/controllers/videos_controller.rb', line 418

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?
                         params[:timestamp_seconds].to_f
                       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



253
254
255
256
257
258
259
# File 'app/controllers/videos_controller.rb', line 253

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

#indexObject



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
82
83
84
# File 'app/controllers/videos_controller.rb', line 53

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



308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'app/controllers/videos_controller.rb', line 308

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



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

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



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

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

#process_cloudflare_updatesObject



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'app/controllers/videos_controller.rb', line 220

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



180
181
182
183
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
# File 'app/controllers/videos_controller.rb', line 180

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



243
244
245
246
247
248
249
250
251
# File 'app/controllers/videos_controller.rb', line 243

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



44
45
46
47
48
49
50
51
# File 'app/controllers/videos_controller.rb', line 44

def show
  @video = Video.find(params[:id])
  if @video.youtube_id.present?
    @youtube_remote_captions = YouTube::RemoteCaptionsStatus.summary(@video)
    @youtube_thumbnail_panel = YouTube::ThumbnailPanelState.summary(@video)
  end
  super
end

#start_poster_extractionObject



459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
# File 'app/controllers/videos_controller.rb', line 459

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 = raw_ts.presence&.to_f

  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



438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
# File 'app/controllers/videos_controller.rb', line 438

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



116
117
118
119
120
# File 'app/controllers/videos_controller.rb', line 116

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

#transcription_optionsObject



176
177
178
# File 'app/controllers/videos_controller.rb', line 176

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

#updateObject

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



163
164
165
166
167
168
169
170
171
172
173
174
# File 'app/controllers/videos_controller.rb', line 163

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

  if @video.update(video_params)
    @video.purge_edge_cache
    Video.purge_edge_cache(index_only: true) if @video.indexed_video?
    redirect_to @video, notice: 'Video was successfully updated.'
  else
    render action: 'edit', status: :unprocessable_entity
  end
end

#update_posterObject



479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
# File 'app/controllers/videos_controller.rb', line 479

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)



657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
# File 'app/controllers/videos_controller.rb', line 657

def youtube_bulk_link
  authorize!(:update, Video)

  pairs = Array(params[:selected_pairs]).map(&:to_s).reject(&: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_findObject



691
692
693
694
695
696
697
698
699
700
# File 'app/controllers/videos_controller.rb', line 691

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


702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
# File 'app/controllers/videos_controller.rb', line 702

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



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

def youtube_match_candidates
  authorize!(:update, Video)

  @videos_by_id = {}
  service = YouTube::AutoLinkService.new
  @matches = service.discover_matches(local_scope: Video.active)
  ids = @matches.map { |m| m[: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_push_captionsObject



528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
# File 'app/controllers/videos_controller.rb', line 528

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



605
606
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/controllers/videos_controller.rb', line 605

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

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

  raw = @video.youtube_chapters_draft
  if raw.blank?
    redirect_to @video, alert: 'No saved chapter draft. Generate chapters first and wait until they appear below.'
    return
  end

  YouTube::ChapterService.new.push_prepared_chapters(@video, raw)
  @video.update!(
    youtube_chapters_draft: nil,
    youtube_chapters_generation_status: nil,
    youtube_chapters_generation_error: nil
  )
  redirect_to @video, notice: 'Chapters were prepended 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_thumbnailObject



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

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



567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
# File 'app/controllers/videos_controller.rb', line 567

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,
        youtube_chapters_draft: 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_syncObject

--- YouTube API actions ---



501
502
503
504
505
506
507
508
509
510
511
512
# File 'app/controllers/videos_controller.rb', line 501

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

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

  YouTubeSyncWorker.perform_async(@video.id)
  redirect_to @video, notice: 'YouTube sync queued. Metadata will be updated shortly.'
end

#youtube_sync_allObject



633
634
635
636
637
638
# File 'app/controllers/videos_controller.rb', line 633

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_uploadObject



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

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