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 +4 -4
- data/README.md +144 -6
- data/app/controllers/helios/videos/admin/base_controller.rb +1 -1
- data/app/controllers/helios/videos/admin/videos_controller.rb +9 -1
- data/app/jobs/helios/videos/migration/check_ingestion_job.rb +40 -0
- data/app/jobs/helios/videos/migration/convert_video_job.rb +61 -0
- data/app/jobs/helios/videos/migration/migrate_videos_job.rb +31 -0
- data/app/models/concerns/helios/videos/video_concern.rb +81 -0
- data/app/models/helios/videos/video.rb +2 -47
- data/config/routes.rb +1 -1
- data/db/migrate/20250510000001_create_helios_videos_videos.rb +2 -0
- data/db/migrate/20250524000001_add_provider_to_helios_videos_videos.rb +8 -0
- data/lib/helios/videos/configuration.rb +2 -0
- data/lib/helios/videos/engine.rb +14 -2
- data/lib/helios/videos/processor.rb +18 -0
- data/lib/helios/videos/processors/cloudflare.rb +76 -2
- data/lib/helios/videos/processors/mux.rb +65 -3
- data/lib/helios/videos/version.rb +1 -1
- data/lib/helios/videos.rb +10 -2
- data/lib/helios-videos.rb +1 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5e3128ca8453dd9d7bb22e3cc19315474a4dd398d3ba228a212ed54e9c8e2818
|
|
4
|
+
data.tar.gz: f7ad28e93d4fb7eaebd2965cdee27d26e5f01d0a48aadf0821234a770f1380bc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
39
|
-
# config.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: "/
|
|
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
|
|
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
|
-
|
|
200
|
+
If your host app needs the video block Stimulus controller:
|
|
76
201
|
|
|
77
202
|
```javascript
|
|
78
|
-
import { HeliosVideoBlockController } from "helios
|
|
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.
|
|
@@ -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 =
|
|
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
|
@@ -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
|
|
@@ -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"]
|
data/lib/helios/videos/engine.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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?
|
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 ||=
|
|
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: #{
|
|
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.
|
|
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
|