helios-videos 0.1.0 → 0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 29b809949f048f47c75bea77d368ef5c74e106abcd02ef2255e4552cba30bcdb
4
- data.tar.gz: 4bd1f685e4e2b80772edc90e5e9ff4a64f1c17e4aa28cad0936540bfd3be7d27
3
+ metadata.gz: 5e3128ca8453dd9d7bb22e3cc19315474a4dd398d3ba228a212ed54e9c8e2818
4
+ data.tar.gz: f7ad28e93d4fb7eaebd2965cdee27d26e5f01d0a48aadf0821234a770f1380bc
5
5
  SHA512:
6
- metadata.gz: aa5eff29b5557d5403002db4aaf971eb4d8181b4283346f07944daaa5bfefb42d9c6f7e8095949a726829c4093979b61ed431bdb977144d5f74cdba4519bae7b
7
- data.tar.gz: d6ca7b0c69d316b7e71356830832561e602dab3622161453a86a38bfd878fdf80d6e0dd9380a9607209b17e0c179927b35ca4d5e81e333a9426d2465ad9755ba
6
+ metadata.gz: 7960c948c478d4fcc24dff6ab49254628727836ba32f7c0d2a5b1a897da932d0f8b143d651466df0c53ce51e32cb16cf439ae248296ae9859d5f8691da3b9c03
7
+ data.tar.gz: 19238d28e62666f25a1165fe1272d3922ed479b5203a9d52dcac93cdccbee604b33b78fc151a7f572b26728ba5533ba4b4abbc0ff2cc523ac0cce4133f0c24e9
data/README.md CHANGED
@@ -29,24 +29,90 @@ Helios::Videos.configure do |config|
29
29
  # Choose your video processor
30
30
  config.processor = :cloudflare # or :mux
31
31
 
32
+ # Parent controller for admin views (must provide authentication)
33
+ config.admin_parent_controller = "Admin::BaseController"
34
+
35
+ # Use an existing model instead of Helios::Videos::Video (optional)
36
+ # Your model must include Helios::Videos::VideoConcern
37
+ # and have the required columns: key, playback_urls (jsonb),
38
+ # requires_signed_urls (boolean), provider (string)
39
+ config.video_model = "Video" # default: "Helios::Videos::Video"
40
+
32
41
  # Cloudflare Stream settings
33
42
  config.cloudflare_account_id = ENV["CLOUDFLARE_ACCOUNT_ID"]
34
43
  config.cloudflare_api_token = ENV["CLOUDFLARE_API_TOKEN"]
44
+ config.cloudflare_customer_subdomain = ENV["CLOUDFLARE_CUSTOMER_SUBDOMAIN"]
35
45
  config.require_signed_urls = true
36
46
 
37
47
  # OR Mux settings
38
- # config.processor = :mux
39
- # config.mux_token_id = ENV["MUX_TOKEN_ID"]
48
+ # config.processor = :mux
49
+ # config.mux_token_id = ENV["MUX_TOKEN_ID"]
40
50
  # config.mux_token_secret = ENV["MUX_TOKEN_SECRET"]
41
51
  end
42
52
  ```
43
53
 
54
+ ## Using an existing model
55
+
56
+ If your app already has a Video model, you can include the gem's functionality via a concern instead of using `Helios::Videos::Video`:
57
+
58
+ 1. Add the required columns to your existing table:
59
+
60
+ ```ruby
61
+ add_column :videos, :key, :string # service identifier (Mux asset ID or Cloudflare UID)
62
+ add_column :videos, :playback_urls, :jsonb
63
+ add_column :videos, :requires_signed_urls, :boolean, default: false, null: false
64
+ add_column :videos, :provider, :string # "cloudflare" or "mux"
65
+ ```
66
+
67
+ 2. Include the concern in your model:
68
+
69
+ ```ruby
70
+ class Video < ApplicationRecord
71
+ include Helios::Videos::VideoConcern
72
+ end
73
+ ```
74
+
75
+ 3. Set the model name in the initializer:
76
+
77
+ ```ruby
78
+ config.video_model = "Video"
79
+ ```
80
+
81
+ The concern adds: `video_file` and `thumbnail_image` ActiveStorage attachments, `provider` enum, automatic ingestion via background job on create, per-video processor routing, signed URL support, and thumbnail downloads.
82
+
83
+ ### Upgrading from pre-0.2.0 (adding the provider column)
84
+
85
+ If you installed helios-videos before the `provider` column was added, you need to add it to your existing table. For apps using the gem's own table:
86
+
87
+ ```bash
88
+ bin/rails helios_videos:install:migrations
89
+ bin/rails db:migrate
90
+ ```
91
+
92
+ For apps using a custom video model with their own table, create a migration:
93
+
94
+ ```ruby
95
+ class AddProviderToVideos < ActiveRecord::Migration[8.0]
96
+ def change
97
+ add_column :videos, :provider, :string
98
+ add_index :videos, :provider
99
+
100
+ # Backfill existing videos with their current provider
101
+ reversible do |dir|
102
+ dir.up do
103
+ Video.where(provider: nil).update_all(provider: 'cloudflare') # or 'mux'
104
+ end
105
+ end
106
+ end
107
+ end
108
+ ```
109
+
44
110
  ## Routes
45
111
 
46
112
  Mount the engine:
47
113
 
48
114
  ```ruby
49
- mount Helios::Videos::Engine, at: "/helios_videos"
115
+ mount Helios::Videos::Engine, at: "/videos"
50
116
  ```
51
117
 
52
118
  ## Usage
@@ -54,7 +120,7 @@ mount Helios::Videos::Engine, at: "/helios_videos"
54
120
  ### Creating a video
55
121
 
56
122
  ```ruby
57
- video = Helios::Videos::Video.new(name: "My Video")
123
+ video = Helios::Videos.video_class.new(name: "My Video")
58
124
  video.video_file.attach(params[:video_file])
59
125
  video.save!
60
126
  # CheckVideoJob will automatically ingest the video into your configured processor
@@ -66,19 +132,91 @@ video.save!
66
132
  <%= video.player_component %>
67
133
  ```
68
134
 
135
+ Each video renders using the correct player for its `provider` — Cloudflare videos use video.js with HLS, Mux videos use the mux-player element. This means both providers can coexist in the same app during and after a migration.
136
+
137
+ ### Programmatic access
138
+
139
+ ```ruby
140
+ video.playback_url # unsigned HLS URL
141
+ video.playback_url(signed: true) # signed URL (Cloudflare)
142
+ video.signed_url(expiration: 2.hours) # shorthand
143
+ video.download_url # downloadable MP4 URL
144
+ video.download_and_store_thumbnail! # fetch and attach thumbnail
145
+ video.check_for_processing! # manually trigger ingestion
146
+ video.effective_processor # the processor for this video's provider
147
+ ```
148
+
69
149
  ### With helios-press
70
150
 
71
151
  When both gems are loaded, video blocks are automatically available in the block editor. Videos can be dragged into block placeholders for direct upload and processing.
72
152
 
153
+ ## Migrating between providers
154
+
155
+ helios-videos includes built-in tooling for migrating videos between Cloudflare Stream and Mux. Migration happens record-by-record in background jobs so long ingestions don't block each other.
156
+
157
+ ### How it works
158
+
159
+ The migration pipeline has three stages:
160
+
161
+ 1. **MigrateVideosJob** (orchestrator) — finds all videos on the source provider, enqueues a conversion job for each one.
162
+ 2. **ConvertVideoJob** (per-video) — gets a download URL from the source, submits it to the destination for ingestion.
163
+ - For **Mux destinations**: playback IDs come back immediately, so the provider is flipped right away.
164
+ - For **Cloudflare destinations**: processing takes time, so a check job is enqueued.
165
+ 3. **CheckIngestionJob** (Cloudflare destination only) — polls Cloudflare every 30 seconds until the video is ready, then flips the provider.
166
+
167
+ ### Running a migration
168
+
169
+ Make sure both providers are configured in your initializer (you need credentials for both), then enqueue the orchestrator:
170
+
171
+ ```ruby
172
+ # Migrate all Cloudflare videos to Mux
173
+ Helios::Videos::Migration::MigrateVideosJob.perform_later(
174
+ from: "cloudflare",
175
+ to: "mux"
176
+ )
177
+
178
+ # Or migrate in batches
179
+ Helios::Videos::Migration::MigrateVideosJob.perform_later(
180
+ from: "cloudflare",
181
+ to: "mux",
182
+ batch_size: 10
183
+ )
184
+ ```
185
+
186
+ During migration, both providers are served concurrently — each video's `provider` column determines which processor handles playback. Videos that haven't been migrated yet continue to work on the original provider.
187
+
188
+ ### Migrate a single video
189
+
190
+ ```ruby
191
+ Helios::Videos::Migration::ConvertVideoJob.perform_later(
192
+ video_id: 42,
193
+ from: "cloudflare",
194
+ to: "mux"
195
+ )
196
+ ```
197
+
73
198
  ## JavaScript
74
199
 
75
- Register the Stimulus controllers in your host app:
200
+ If your host app needs the video block Stimulus controller:
76
201
 
77
202
  ```javascript
78
- import { HeliosVideoBlockController } from "helios-videos"
203
+ import { HeliosVideoBlockController } from "helios/videos"
79
204
  application.register("video-block", HeliosVideoBlockController)
80
205
  ```
81
206
 
207
+ ### Vite
208
+
209
+ If your host app uses Vite, add an alias so Vite can resolve the gem's JavaScript:
210
+
211
+ ```typescript
212
+ // vite.config.mts
213
+ resolve: {
214
+ alias: {
215
+ 'helios/videos': resolve(__dirname, '/path/to/helios-videos/app/javascript/helios/videos'),
216
+ },
217
+ },
218
+ ```
219
+
82
220
  ## License
83
221
 
84
222
  Proprietary. All rights reserved.
@@ -1,7 +1,7 @@
1
1
  module Helios
2
2
  module Videos
3
3
  module Admin
4
- class BaseController < ::ApplicationController
4
+ class BaseController < Helios::Videos.configuration.admin_parent_controller.constantize
5
5
  end
6
6
  end
7
7
  end
@@ -4,6 +4,14 @@ module Helios
4
4
  class VideosController < Helios::Videos::Admin::BaseController
5
5
  before_action :set_video
6
6
 
7
+ def show
8
+ render json: {
9
+ id: @video.id,
10
+ ready: @video.key.present? && @video.playback_urls.present?,
11
+ player_html: @video.key.present? ? @video.player_component : nil
12
+ }
13
+ end
14
+
7
15
  def update
8
16
  if @video.update(video_params)
9
17
  head :ok
@@ -15,7 +23,7 @@ module Helios
15
23
  private
16
24
 
17
25
  def set_video
18
- @video = Video.find(params[:id])
26
+ @video = Helios::Videos.video_class.find(params[:id])
19
27
  end
20
28
 
21
29
  def video_params
@@ -0,0 +1,40 @@
1
+ module Helios
2
+ module Videos
3
+ module Migration
4
+ # Polls the destination provider (Cloudflare) to check if a migrated video
5
+ # has finished processing. Once ready, flips the provider column.
6
+ #
7
+ # Only needed for Cloudflare as a destination — Mux returns playback IDs
8
+ # immediately so no polling is required.
9
+ class CheckIngestionJob < Helios::Videos::ApplicationJob
10
+ queue_as :default
11
+
12
+ MAX_ATTEMPTS = 60 # ~30 minutes with 30s intervals
13
+
14
+ # @param video_id [Integer]
15
+ # @param to [String] destination provider
16
+ # @param attempt [Integer] current attempt number
17
+ def perform(video_id:, to:, attempt: 1)
18
+ video = Helios::Videos.video_class.find(video_id)
19
+ dest_processor = Helios::Videos.processor_for(to.to_sym)
20
+
21
+ if dest_processor.ready?(video)
22
+ video.update!(provider: to)
23
+ Rails.logger.info("[helios-videos] Migration: video #{video_id} is ready on #{to}, provider flipped (attempt #{attempt})")
24
+
25
+ dest_processor.download_thumbnail!(video)
26
+ elsif attempt >= MAX_ATTEMPTS
27
+ Rails.logger.error("[helios-videos] Migration: video #{video_id} still not ready after #{MAX_ATTEMPTS} attempts, giving up")
28
+ else
29
+ Rails.logger.info("[helios-videos] Migration: video #{video_id} not yet ready on #{to}, retrying (attempt #{attempt})")
30
+ CheckIngestionJob.set(wait: 30.seconds).perform_later(
31
+ video_id: video_id,
32
+ to: to,
33
+ attempt: attempt + 1
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,61 @@
1
+ module Helios
2
+ module Videos
3
+ module Migration
4
+ # Per-video conversion job: gets a download URL from the source provider,
5
+ # submits it to the destination provider for ingestion, and updates the
6
+ # video record.
7
+ #
8
+ # For Mux destinations, the playback ID comes back immediately so the
9
+ # provider is flipped right away.
10
+ #
11
+ # For Cloudflare destinations, a CheckIngestionJob is enqueued to poll
12
+ # for readiness before flipping the provider.
13
+ class ConvertVideoJob < Helios::Videos::ApplicationJob
14
+ queue_as :default
15
+
16
+ # @param video_id [Integer]
17
+ # @param from [String] source provider
18
+ # @param to [String] destination provider
19
+ def perform(video_id:, from:, to:)
20
+ video = Helios::Videos.video_class.find(video_id)
21
+
22
+ unless video.provider == from
23
+ Rails.logger.info("[helios-videos] Migration: video #{video_id} is already on #{video.provider}, skipping")
24
+ return
25
+ end
26
+
27
+ source_processor = Helios::Videos.processor_for(from.to_sym)
28
+ dest_processor = Helios::Videos.processor_for(to.to_sym)
29
+
30
+ # Get download URL from source
31
+ source_url = source_processor.download_url(video, expiration: 12.hours)
32
+ unless source_url.present?
33
+ Rails.logger.error("[helios-videos] Migration: could not get download URL for video #{video_id} from #{from}")
34
+ return
35
+ end
36
+
37
+ Rails.logger.info("[helios-videos] Migration: ingesting video #{video_id} from #{from} -> #{to}")
38
+
39
+ # Submit to destination processor
40
+ dest_processor.ingest_from_url!(video, source_url, provider: to)
41
+
42
+ if to.to_sym == :mux
43
+ # Mux returns playback IDs immediately — flip provider now
44
+ video.update!(provider: to)
45
+ Rails.logger.info("[helios-videos] Migration: video #{video_id} migrated to #{to}")
46
+
47
+ # Download new thumbnail from the new provider
48
+ dest_processor.download_thumbnail!(video)
49
+ else
50
+ # Cloudflare needs processing time — enqueue a check
51
+ CheckIngestionJob.set(wait: 30.seconds).perform_later(
52
+ video_id: video_id,
53
+ to: to,
54
+ attempt: 1
55
+ )
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,31 @@
1
+ module Helios
2
+ module Videos
3
+ module Migration
4
+ # Orchestrator job: finds all videos on the source provider and enqueues
5
+ # a ConvertVideoJob for each one. Processes record-by-record so long
6
+ # ingestions don't block each other.
7
+ class MigrateVideosJob < Helios::Videos::ApplicationJob
8
+ queue_as :default
9
+
10
+ # @param from [String] source provider ("cloudflare" or "mux")
11
+ # @param to [String] destination provider ("cloudflare" or "mux")
12
+ # @param batch_size [Integer] how many to enqueue per run (default: all)
13
+ def perform(from:, to:, batch_size: nil)
14
+ video_class = Helios::Videos.video_class
15
+
16
+ videos = video_class.where(provider: from).where.not(key: [nil, ""])
17
+ videos = videos.limit(batch_size) if batch_size.present?
18
+
19
+ total = videos.count
20
+ Rails.logger.info("[helios-videos] Migration: enqueuing #{total} videos from #{from} -> #{to}")
21
+
22
+ videos.find_each do |video|
23
+ ConvertVideoJob.perform_later(video_id: video.id, from: from, to: to)
24
+ end
25
+
26
+ Rails.logger.info("[helios-videos] Migration: all #{total} conversion jobs enqueued")
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,81 @@
1
+ module Helios
2
+ module Videos
3
+ module VideoConcern
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ has_one_attached :video_file
8
+ has_one_attached :thumbnail_image
9
+
10
+ enum :provider, { cloudflare: 'cloudflare', mux: 'mux' }
11
+
12
+ if defined?(Helios::Press::Block)
13
+ belongs_to :block, class_name: "Helios::Press::Block", optional: true
14
+ end
15
+
16
+ before_validation :set_default_provider, on: :create
17
+ after_create :enqueue_check_for_video
18
+ before_destroy :delete_from_service
19
+ end
20
+
21
+ # Returns the processor for this specific video based on its provider column,
22
+ # falling back to the globally configured processor.
23
+ def effective_processor
24
+ if respond_to?(:provider) && provider.present?
25
+ Helios::Videos.processor_for(provider.to_sym)
26
+ else
27
+ Helios::Videos.processor
28
+ end
29
+ end
30
+
31
+ def player_component(muted: false, expiration: 4.hours)
32
+ effective_processor.player_component(self, muted: muted, expiration: expiration)
33
+ end
34
+
35
+ def playback_url(signed: false, expiration: 4.hours)
36
+ effective_processor.playback_url(self, signed: signed, expiration: expiration)
37
+ end
38
+
39
+ def signed_url(expiration: 4.hours)
40
+ playback_url(signed: true, expiration: expiration)
41
+ end
42
+
43
+ def download_url(expiration: 4.hours)
44
+ effective_processor.download_url(self, expiration: expiration)
45
+ end
46
+
47
+ def download_and_store_thumbnail!(time: "3s")
48
+ effective_processor.download_thumbnail!(self, time: time)
49
+ end
50
+
51
+ def thumbnail_url(time: "4s")
52
+ thumbnail_image.attached? ? thumbnail_image : nil
53
+ end
54
+
55
+ def requires_signed_urls?
56
+ respond_to?(:requires_signed_urls) ? super : false
57
+ end
58
+
59
+ def check_for_processing!
60
+ effective_processor.ingest!(self)
61
+ end
62
+
63
+ private
64
+
65
+ def set_default_provider
66
+ self.provider ||= Helios::Videos.configuration.processor.to_s
67
+ end
68
+
69
+ def enqueue_check_for_video
70
+ Helios::Videos::CheckVideoJob.perform_later(video: self)
71
+ end
72
+
73
+ def delete_from_service
74
+ effective_processor.delete!(self)
75
+ rescue => e
76
+ Rails.logger.warn("[helios-videos] Failed to delete video #{id} from service: #{e.message}")
77
+ true # Don't block destroy
78
+ end
79
+ end
80
+ end
81
+ end
@@ -3,57 +3,12 @@ module Helios
3
3
  class Video < ActiveRecord::Base
4
4
  self.table_name = "helios_videos_videos"
5
5
 
6
+ include Helios::Videos::VideoConcern
7
+
6
8
  # Optional association to helios-press block (when both gems are loaded)
7
9
  if defined?(Helios::Press::Block)
8
10
  belongs_to :block, class_name: "Helios::Press::Block", optional: true
9
11
  end
10
-
11
- has_one_attached :video_file
12
- has_one_attached :thumbnail_image
13
-
14
- after_create :enqueue_check_for_video
15
- before_destroy :delete_from_service
16
-
17
- def player_component(muted: false, expiration: 4.hours)
18
- Helios::Videos.processor.player_component(self, muted: muted, expiration: expiration)
19
- end
20
-
21
- def playback_url(signed: false, expiration: 4.hours)
22
- Helios::Videos.processor.playback_url(self, signed: signed, expiration: expiration)
23
- end
24
-
25
- def signed_url(expiration: 4.hours)
26
- playback_url(signed: true, expiration: expiration)
27
- end
28
-
29
- def download_and_store_thumbnail!(time: "3s")
30
- Helios::Videos.processor.download_thumbnail!(self, time: time)
31
- end
32
-
33
- def thumbnail_url(time: "4s")
34
- thumbnail_image.attached? ? thumbnail_image : nil
35
- end
36
-
37
- def requires_signed_urls?
38
- respond_to?(:requires_signed_urls) ? requires_signed_urls : false
39
- end
40
-
41
- def check_for_processing!
42
- Helios::Videos.processor.ingest!(self)
43
- end
44
-
45
- private
46
-
47
- def enqueue_check_for_video
48
- CheckVideoJob.perform_later(video: self)
49
- end
50
-
51
- def delete_from_service
52
- Helios::Videos.processor.delete!(self)
53
- rescue => e
54
- Rails.logger.warn("[helios-videos] Failed to delete video #{id} from service: #{e.message}")
55
- true # Don't block destroy
56
- end
57
12
  end
58
13
  end
59
14
  end
data/config/routes.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  Helios::Videos::Engine.routes.draw do
2
2
  namespace :admin do
3
- resources :videos, only: [:update]
3
+ resources :videos, only: [:show, :update]
4
4
  end
5
5
  end
@@ -5,6 +5,7 @@ class CreateHeliosVideosVideos < ActiveRecord::Migration[8.0]
5
5
  t.string :key
6
6
  t.jsonb :playback_urls
7
7
  t.boolean :requires_signed_urls, default: false, null: false
8
+ t.string :provider
8
9
  t.integer :block_id
9
10
 
10
11
  t.timestamps
@@ -12,5 +13,6 @@ class CreateHeliosVideosVideos < ActiveRecord::Migration[8.0]
12
13
 
13
14
  add_index :helios_videos_videos, :block_id
14
15
  add_index :helios_videos_videos, :key, unique: true
16
+ add_index :helios_videos_videos, :provider
15
17
  end
16
18
  end
@@ -0,0 +1,8 @@
1
+ class AddProviderToHeliosVideosVideos < ActiveRecord::Migration[8.0]
2
+ def change
3
+ unless column_exists?(:helios_videos_videos, :provider)
4
+ add_column :helios_videos_videos, :provider, :string
5
+ add_index :helios_videos_videos, :provider
6
+ end
7
+ end
8
+ end
@@ -4,6 +4,7 @@ module Helios
4
4
  attr_accessor :processor,
5
5
  :require_signed_urls,
6
6
  :admin_parent_controller,
7
+ :video_model,
7
8
  # Cloudflare
8
9
  :cloudflare_account_id,
9
10
  :cloudflare_api_token,
@@ -16,6 +17,7 @@ module Helios
16
17
  @processor = :cloudflare
17
18
  @require_signed_urls = true
18
19
  @admin_parent_controller = "ApplicationController"
20
+ @video_model = "Helios::Videos::Video"
19
21
  @cloudflare_account_id = ENV["CLOUDFLARE_ACCOUNT_ID"]
20
22
  @cloudflare_api_token = ENV["CLOUDFLARE_API_TOKEN"]
21
23
  @cloudflare_customer_subdomain = ENV["CLOUDFLARE_CUSTOMER_SUBDOMAIN"]
@@ -4,11 +4,23 @@ module Helios
4
4
  isolate_namespace Helios::Videos
5
5
 
6
6
  # When helios-press is also loaded, mix video support into Block
7
- initializer "helios_videos.integrate_with_press" do
7
+ initializer "helios_videos.integrate_with_press", after: :load_config_initializers do
8
8
  ActiveSupport.on_load(:active_record) do
9
9
  if defined?(Helios::Press::Block)
10
+ unless Helios::Press::Block.reflect_on_association(:video)
11
+ Helios::Press::Block.class_eval do
12
+ has_one :video, class_name: Helios::Videos.configuration.video_model, foreign_key: :block_id, dependent: :destroy
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+
19
+ config.to_prepare do
20
+ if defined?(Helios::Press::Block)
21
+ unless Helios::Press::Block.reflect_on_association(:video)
10
22
  Helios::Press::Block.class_eval do
11
- has_one :video, class_name: "Helios::Videos::Video", foreign_key: :block_id, dependent: :destroy
23
+ has_one :video, class_name: Helios::Videos.configuration.video_model, foreign_key: :block_id, dependent: :destroy
12
24
  end
13
25
  end
14
26
  end
@@ -33,6 +33,24 @@ module Helios
33
33
  raise NotImplementedError, "#{self.class}#signed_token must be implemented"
34
34
  end
35
35
 
36
+ # Return a downloadable MP4 URL for the video (used for migration between providers).
37
+ def download_url(video, expiration: 4.hours)
38
+ raise NotImplementedError, "#{self.class}#download_url must be implemented"
39
+ end
40
+
41
+ # Ingest a video from an external URL (used during migration between providers).
42
+ # Unlike ingest!, this takes a source URL directly rather than using ActiveStorage.
43
+ # Should set new key and playback_urls on the video without changing provider.
44
+ def ingest_from_url!(video, source_url, provider:)
45
+ raise NotImplementedError, "#{self.class}#ingest_from_url! must be implemented"
46
+ end
47
+
48
+ # Check whether a video has finished processing and is ready for playback.
49
+ # Used by migration CheckIngestionJob to poll for readiness.
50
+ def ready?(video)
51
+ raise NotImplementedError, "#{self.class}#ready? must be implemented"
52
+ end
53
+
36
54
  # Download and store the video thumbnail.
37
55
  def download_thumbnail!(video, time: "3s")
38
56
  raise NotImplementedError, "#{self.class}#download_thumbnail! must be implemented"
@@ -66,6 +66,64 @@ module Helios
66
66
  end
67
67
  end
68
68
 
69
+ def ingest_from_url!(video, source_url, provider:)
70
+ uri = URI("https://api.cloudflare.com/client/v4/accounts/#{account_id}/stream/copy")
71
+ http = Net::HTTP.new(uri.host, uri.port)
72
+ http.use_ssl = true
73
+
74
+ request = Net::HTTP::Post.new(uri.request_uri, {
75
+ "Authorization" => "Bearer #{api_token}",
76
+ "Content-Type" => "application/json"
77
+ })
78
+
79
+ request.body = {
80
+ url: source_url,
81
+ meta: { name: video.name },
82
+ requireSignedURLs: config.require_signed_urls
83
+ }.to_json
84
+
85
+ response = http.request(request)
86
+ body = JSON.parse(response.body)
87
+
88
+ unless response.is_a?(Net::HTTPSuccess)
89
+ Rails.logger.error("[helios-videos] Cloudflare migration ingest error: #{response.code} - #{response.body}")
90
+ raise "Cloudflare migration ingest failed: #{response.code}"
91
+ end
92
+
93
+ # Store the new Cloudflare key and playback URLs, but don't flip provider yet.
94
+ # The CheckIngestionJob will flip it once the video is ready.
95
+ video.update!(
96
+ key: body["result"]["uid"],
97
+ playback_urls: body["result"]["playback"],
98
+ requires_signed_urls: config.require_signed_urls
99
+ )
100
+
101
+ Rails.logger.info("[helios-videos] Migration: video #{video.id} submitted to Cloudflare: #{video.key}")
102
+ end
103
+
104
+ def ready?(video)
105
+ return false unless video.key.present?
106
+
107
+ uri = URI("https://api.cloudflare.com/client/v4/accounts/#{account_id}/stream/#{video.key}")
108
+ http = Net::HTTP.new(uri.host, uri.port)
109
+ http.use_ssl = true
110
+
111
+ request = Net::HTTP::Get.new(uri.request_uri, {
112
+ "Authorization" => "Bearer #{api_token}",
113
+ "Content-Type" => "application/json"
114
+ })
115
+
116
+ response = http.request(request)
117
+ return false unless response.is_a?(Net::HTTPSuccess)
118
+
119
+ body = JSON.parse(response.body)
120
+ status = body.dig("result", "status", "state")
121
+ status == "ready"
122
+ rescue => e
123
+ Rails.logger.warn("[helios-videos] Migration: error checking readiness for video #{video.id}: #{e.message}")
124
+ false
125
+ end
126
+
69
127
  def playback_url(video, signed: false, expiration: 4.hours)
70
128
  return nil unless video.key.present?
71
129
 
@@ -79,7 +137,14 @@ module Helios
79
137
  end
80
138
 
81
139
  def player_component(video, muted: false, expiration: 4.hours)
82
- return "(VIDEO NOT AVAILABLE)" unless video.key.present?
140
+ unless video.key.present?
141
+ return <<~HTML.html_safe
142
+ <div class="text-center p-4 text-muted">
143
+ <div class="spinner-border spinner-border-sm me-2" role="status"></div>
144
+ Processing video...
145
+ </div>
146
+ HTML
147
+ end
83
148
 
84
149
  video_src = playback_url(video, signed: video.requires_signed_urls?, expiration: expiration)
85
150
 
@@ -99,7 +164,7 @@ module Helios
99
164
  HTML
100
165
  end
101
166
 
102
- def signed_token(video, expiration: 4.hours)
167
+ def signed_token(video, expiration: 4.hours, downloadable: false)
103
168
  key_id, private_key = fetch_signing_key
104
169
 
105
170
  now = Time.now.to_i
@@ -109,10 +174,19 @@ module Helios
109
174
  exp: now + expiration.to_i,
110
175
  nbf: now - 60
111
176
  }
177
+ payload[:downloadable] = true if downloadable
112
178
 
113
179
  JWT.encode(payload, private_key, "RS256", typ: "JWT", kid: key_id)
114
180
  end
115
181
 
182
+ def download_url(video, expiration: 4.hours)
183
+ return nil unless video.key.present?
184
+
185
+ token = signed_token(video, expiration: expiration, downloadable: true)
186
+ subdomain = customer_subdomain(video)
187
+ "https://#{subdomain}.cloudflarestream.com/#{token}/downloads/default.mp4"
188
+ end
189
+
116
190
  def download_thumbnail!(video, time: "3s")
117
191
  return false unless video.key.present?
118
192
  return true if video.thumbnail_image.attached?
@@ -14,8 +14,9 @@ module Helios
14
14
  end
15
15
 
16
16
  assets_api = MuxRuby::AssetsApi.new
17
+ input_settings = MuxRuby::InputSettings.new(url: video.video_file.url)
17
18
  create_request = MuxRuby::CreateAssetRequest.new(
18
- input: video.video_file.url,
19
+ input: [input_settings],
19
20
  playback_policy: [MuxRuby::PlaybackPolicy::PUBLIC]
20
21
  )
21
22
 
@@ -30,6 +31,51 @@ module Helios
30
31
  Rails.logger.info("[helios-videos] Video #{video.id} ingested into Mux: #{video.key}")
31
32
  end
32
33
 
34
+ def ingest_from_url!(video, source_url, provider:)
35
+ require "mux_ruby"
36
+
37
+ MuxRuby.configure do |c|
38
+ c.username = config.mux_token_id
39
+ c.password = config.mux_token_secret
40
+ end
41
+
42
+ assets_api = MuxRuby::AssetsApi.new
43
+ input_settings = MuxRuby::InputSettings.new(url: source_url)
44
+ create_request = MuxRuby::CreateAssetRequest.new(
45
+ input: [input_settings],
46
+ playback_policy: [MuxRuby::PlaybackPolicy::PUBLIC],
47
+ mp4_support: "standard"
48
+ )
49
+
50
+ response = assets_api.create_asset(create_request)
51
+ asset = response.data
52
+
53
+ video.update!(
54
+ key: asset.id,
55
+ playback_urls: { "hls" => "https://stream.mux.com/#{asset.playback_ids&.first&.id}.m3u8" }
56
+ )
57
+
58
+ Rails.logger.info("[helios-videos] Migration: video #{video.id} ingested into Mux: #{video.key}")
59
+ end
60
+
61
+ def ready?(video)
62
+ return false unless video.key.present?
63
+
64
+ require "mux_ruby"
65
+
66
+ MuxRuby.configure do |c|
67
+ c.username = config.mux_token_id
68
+ c.password = config.mux_token_secret
69
+ end
70
+
71
+ assets_api = MuxRuby::AssetsApi.new
72
+ asset = assets_api.get_asset(video.key)
73
+ asset.data.status == "ready"
74
+ rescue => e
75
+ Rails.logger.warn("[helios-videos] Migration: error checking Mux readiness for video #{video.id}: #{e.message}")
76
+ false
77
+ end
78
+
33
79
  def delete!(video)
34
80
  return unless video.key.present?
35
81
 
@@ -53,10 +99,16 @@ module Helios
53
99
  end
54
100
 
55
101
  def player_component(video, muted: false, expiration: 4.hours)
56
- return "(VIDEO NOT AVAILABLE)" unless video.key.present?
102
+ unless video.key.present? && extract_playback_id(video).present?
103
+ return <<~HTML.html_safe
104
+ <div class="text-center p-4 text-muted">
105
+ <div class="spinner-border spinner-border-sm me-2" role="status"></div>
106
+ Processing video...
107
+ </div>
108
+ HTML
109
+ end
57
110
 
58
111
  playback_id = extract_playback_id(video)
59
- return "(VIDEO NOT AVAILABLE)" unless playback_id.present?
60
112
 
61
113
  <<~HTML.html_safe
62
114
  <mux-player
@@ -73,6 +125,16 @@ module Helios
73
125
  nil # Mux uses playback IDs, not signed tokens in the same way
74
126
  end
75
127
 
128
+ def download_url(video, expiration: 4.hours)
129
+ return nil unless video.key.present?
130
+
131
+ playback_id = extract_playback_id(video)
132
+ return nil unless playback_id.present?
133
+
134
+ # Mux provides static renditions at predictable URLs
135
+ "https://stream.mux.com/#{playback_id}/high.mp4"
136
+ end
137
+
76
138
  def download_thumbnail!(video, time: "3s")
77
139
  return false unless video.key.present?
78
140
  return true if video.thumbnail_image.attached?
@@ -1,5 +1,5 @@
1
1
  module Helios
2
2
  module Videos
3
- VERSION = "0.1.0"
3
+ VERSION = "0.2"
4
4
  end
5
5
  end
data/lib/helios/videos.rb CHANGED
@@ -16,16 +16,24 @@ module Helios
16
16
  yield(configuration)
17
17
  end
18
18
 
19
+ def video_class
20
+ configuration.video_model.constantize
21
+ end
22
+
19
23
  def processor
20
24
  @processor = nil if @processor_type != configuration.processor
21
25
  @processor_type = configuration.processor
22
- @processor ||= case configuration.processor
26
+ @processor ||= processor_for(configuration.processor)
27
+ end
28
+
29
+ def processor_for(provider_type)
30
+ case provider_type.to_sym
23
31
  when :cloudflare
24
32
  Processors::Cloudflare.new(configuration)
25
33
  when :mux
26
34
  Processors::Mux.new(configuration)
27
35
  else
28
- raise "Unknown video processor: #{configuration.processor}. Use :cloudflare or :mux"
36
+ raise "Unknown video processor: #{provider_type}. Use :cloudflare or :mux"
29
37
  end
30
38
  end
31
39
  end
@@ -0,0 +1 @@
1
+ require "helios/videos"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: helios-videos
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: '0.2'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jason Fleetwood-Boldt
@@ -57,12 +57,18 @@ files:
57
57
  - app/javascript/helios/videos/index.js
58
58
  - app/jobs/helios/videos/application_job.rb
59
59
  - app/jobs/helios/videos/check_video_job.rb
60
+ - app/jobs/helios/videos/migration/check_ingestion_job.rb
61
+ - app/jobs/helios/videos/migration/convert_video_job.rb
62
+ - app/jobs/helios/videos/migration/migrate_videos_job.rb
60
63
  - app/mailers/helios/videos/application_mailer.rb
64
+ - app/models/concerns/helios/videos/video_concern.rb
61
65
  - app/models/helios/videos/application_record.rb
62
66
  - app/models/helios/videos/video.rb
63
67
  - app/views/layouts/helios/videos/application.html.erb
64
68
  - config/routes.rb
65
69
  - db/migrate/20250510000001_create_helios_videos_videos.rb
70
+ - db/migrate/20250524000001_add_provider_to_helios_videos_videos.rb
71
+ - lib/helios-videos.rb
66
72
  - lib/helios/videos.rb
67
73
  - lib/helios/videos/configuration.rb
68
74
  - lib/helios/videos/engine.rb