helios-videos 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 29b809949f048f47c75bea77d368ef5c74e106abcd02ef2255e4552cba30bcdb
4
+ data.tar.gz: 4bd1f685e4e2b80772edc90e5e9ff4a64f1c17e4aa28cad0936540bfd3be7d27
5
+ SHA512:
6
+ metadata.gz: aa5eff29b5557d5403002db4aaf971eb4d8181b4283346f07944daaa5bfefb42d9c6f7e8095949a726829c4093979b61ed431bdb977144d5f74cdba4519bae7b
7
+ data.tar.gz: d6ca7b0c69d316b7e71356830832561e602dab3622161453a86a38bfd878fdf80d6e0dd9380a9607209b17e0c179927b35ca4d5e81e333a9426d2465ad9755ba
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright TODO: Write your name
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # Helios::Videos
2
+
3
+ Video upload, processing, and streaming for Rails. Upload videos via ActiveStorage direct upload to S3, ingest into Mux or Cloudflare Stream for processing, and serve streaming video.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "helios-videos"
11
+ ```
12
+
13
+ Then:
14
+
15
+ ```bash
16
+ bundle install
17
+ bin/rails helios_videos:install:migrations
18
+ bin/rails db:migrate
19
+ ```
20
+
21
+ **Prerequisites:** Your host app must have ActiveStorage installed (`bin/rails active_storage:install`).
22
+
23
+ ## Configuration
24
+
25
+ Create `config/initializers/helios_videos.rb`:
26
+
27
+ ```ruby
28
+ Helios::Videos.configure do |config|
29
+ # Choose your video processor
30
+ config.processor = :cloudflare # or :mux
31
+
32
+ # Cloudflare Stream settings
33
+ config.cloudflare_account_id = ENV["CLOUDFLARE_ACCOUNT_ID"]
34
+ config.cloudflare_api_token = ENV["CLOUDFLARE_API_TOKEN"]
35
+ config.require_signed_urls = true
36
+
37
+ # OR Mux settings
38
+ # config.processor = :mux
39
+ # config.mux_token_id = ENV["MUX_TOKEN_ID"]
40
+ # config.mux_token_secret = ENV["MUX_TOKEN_SECRET"]
41
+ end
42
+ ```
43
+
44
+ ## Routes
45
+
46
+ Mount the engine:
47
+
48
+ ```ruby
49
+ mount Helios::Videos::Engine, at: "/helios_videos"
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ### Creating a video
55
+
56
+ ```ruby
57
+ video = Helios::Videos::Video.new(name: "My Video")
58
+ video.video_file.attach(params[:video_file])
59
+ video.save!
60
+ # CheckVideoJob will automatically ingest the video into your configured processor
61
+ ```
62
+
63
+ ### Displaying a video
64
+
65
+ ```erb
66
+ <%= video.player_component %>
67
+ ```
68
+
69
+ ### With helios-press
70
+
71
+ 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
+
73
+ ## JavaScript
74
+
75
+ Register the Stimulus controllers in your host app:
76
+
77
+ ```javascript
78
+ import { HeliosVideoBlockController } from "helios-videos"
79
+ application.register("video-block", HeliosVideoBlockController)
80
+ ```
81
+
82
+ ## License
83
+
84
+ Proprietary. All rights reserved.
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,8 @@
1
+ module Helios
2
+ module Videos
3
+ module Admin
4
+ class BaseController < ::ApplicationController
5
+ end
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,27 @@
1
+ module Helios
2
+ module Videos
3
+ module Admin
4
+ class VideosController < Helios::Videos::Admin::BaseController
5
+ before_action :set_video
6
+
7
+ def update
8
+ if @video.update(video_params)
9
+ head :ok
10
+ else
11
+ head :unprocessable_entity
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def set_video
18
+ @video = Video.find(params[:id])
19
+ end
20
+
21
+ def video_params
22
+ params.require(:video).permit(:name)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,6 @@
1
+ module Helios
2
+ module Videos
3
+ class ApplicationController < ActionController::Base
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ module Helios
2
+ module Videos
3
+ module ApplicationHelper
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,47 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Manages inline name editing for video blocks
4
+ export default class extends Controller {
5
+ static values = {
6
+ videoId: Number
7
+ }
8
+
9
+ editName(event) {
10
+ const viewName = event.currentTarget
11
+ const editName = viewName.nextElementSibling
12
+
13
+ viewName.classList.add('d-none')
14
+ editName.classList.remove('d-none')
15
+ editName.querySelector('input').focus()
16
+ }
17
+
18
+ saveName(event) {
19
+ const input = event.currentTarget
20
+ const name = input.value
21
+
22
+ fetch(`/helios_videos/admin/videos/${this.videoIdValue}`, {
23
+ method: 'PATCH',
24
+ headers: {
25
+ 'Content-Type': 'application/json',
26
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
27
+ },
28
+ body: JSON.stringify({ video: { name: name } })
29
+ })
30
+ .then(() => {
31
+ const editName = input.closest('.name-edit')
32
+ const viewName = editName.previousElementSibling
33
+
34
+ viewName.textContent = name || 'Click to add name...'
35
+ viewName.classList.remove('d-none')
36
+ editName.classList.add('d-none')
37
+ })
38
+ }
39
+
40
+ cancelName(event) {
41
+ const editName = event.currentTarget.closest('.name-edit')
42
+ const viewName = editName.previousElementSibling
43
+
44
+ viewName.classList.remove('d-none')
45
+ editName.classList.add('d-none')
46
+ }
47
+ }
@@ -0,0 +1,4 @@
1
+ // Helios Videos - Stimulus Controllers
2
+ // Import these in your host app's application.js or controllers/index.js
3
+
4
+ export { default as HeliosVideoBlockController } from "./controllers/video_block_controller"
@@ -0,0 +1,6 @@
1
+ module Helios
2
+ module Videos
3
+ class ApplicationJob < ActiveJob::Base
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,32 @@
1
+ module Helios
2
+ module Videos
3
+ class CheckVideoJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ def perform(video:, attempt_thumbnail: false)
7
+ if video.key.present?
8
+ Rails.logger.debug("[helios-videos] Video #{video.id} already ingested (key: #{video.key})")
9
+
10
+ if attempt_thumbnail && !video.thumbnail_image.attached?
11
+ success = video.download_and_store_thumbnail!
12
+ unless success
13
+ CheckVideoJob.set(wait: 30.seconds).perform_later(video: video, attempt_thumbnail: true)
14
+ end
15
+ end
16
+ return
17
+ end
18
+
19
+ if video.video_file.attached?
20
+ Rails.logger.debug("[helios-videos] Video #{video.id} file attached, starting ingestion...")
21
+ video.check_for_processing!
22
+
23
+ # Schedule thumbnail download after processing
24
+ CheckVideoJob.set(wait: 10.seconds).perform_later(video: video, attempt_thumbnail: true)
25
+ else
26
+ Rails.logger.debug("[helios-videos] Video #{video.id} file not attached, retrying in 5s...")
27
+ CheckVideoJob.set(wait: 5.seconds).perform_later(video: video, attempt_thumbnail: attempt_thumbnail)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,8 @@
1
+ module Helios
2
+ module Videos
3
+ class ApplicationMailer < ActionMailer::Base
4
+ default from: "from@example.com"
5
+ layout "mailer"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ module Helios
2
+ module Videos
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,59 @@
1
+ module Helios
2
+ module Videos
3
+ class Video < ActiveRecord::Base
4
+ self.table_name = "helios_videos_videos"
5
+
6
+ # Optional association to helios-press block (when both gems are loaded)
7
+ if defined?(Helios::Press::Block)
8
+ belongs_to :block, class_name: "Helios::Press::Block", optional: true
9
+ 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
+ end
58
+ end
59
+ end
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Helios videos</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <%= stylesheet_link_tag "helios/videos/application", media: "all" %>
11
+ </head>
12
+ <body>
13
+
14
+ <%= yield %>
15
+
16
+ </body>
17
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ Helios::Videos::Engine.routes.draw do
2
+ namespace :admin do
3
+ resources :videos, only: [:update]
4
+ end
5
+ end
@@ -0,0 +1,16 @@
1
+ class CreateHeliosVideosVideos < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :helios_videos_videos do |t|
4
+ t.string :name
5
+ t.string :key
6
+ t.jsonb :playback_urls
7
+ t.boolean :requires_signed_urls, default: false, null: false
8
+ t.integer :block_id
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :helios_videos_videos, :block_id
14
+ add_index :helios_videos_videos, :key, unique: true
15
+ end
16
+ end
@@ -0,0 +1,27 @@
1
+ module Helios
2
+ module Videos
3
+ class Configuration
4
+ attr_accessor :processor,
5
+ :require_signed_urls,
6
+ :admin_parent_controller,
7
+ # Cloudflare
8
+ :cloudflare_account_id,
9
+ :cloudflare_api_token,
10
+ :cloudflare_customer_subdomain,
11
+ # Mux
12
+ :mux_token_id,
13
+ :mux_token_secret
14
+
15
+ def initialize
16
+ @processor = :cloudflare
17
+ @require_signed_urls = true
18
+ @admin_parent_controller = "ApplicationController"
19
+ @cloudflare_account_id = ENV["CLOUDFLARE_ACCOUNT_ID"]
20
+ @cloudflare_api_token = ENV["CLOUDFLARE_API_TOKEN"]
21
+ @cloudflare_customer_subdomain = ENV["CLOUDFLARE_CUSTOMER_SUBDOMAIN"]
22
+ @mux_token_id = ENV["MUX_TOKEN_ID"]
23
+ @mux_token_secret = ENV["MUX_TOKEN_SECRET"]
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,18 @@
1
+ module Helios
2
+ module Videos
3
+ class Engine < ::Rails::Engine
4
+ isolate_namespace Helios::Videos
5
+
6
+ # When helios-press is also loaded, mix video support into Block
7
+ initializer "helios_videos.integrate_with_press" do
8
+ ActiveSupport.on_load(:active_record) do
9
+ if defined?(Helios::Press::Block)
10
+ Helios::Press::Block.class_eval do
11
+ has_one :video, class_name: "Helios::Videos::Video", foreign_key: :block_id, dependent: :destroy
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,42 @@
1
+ module Helios
2
+ module Videos
3
+ class Processor
4
+ attr_reader :config
5
+
6
+ def initialize(config)
7
+ @config = config
8
+ end
9
+
10
+ # Ingest a video from its ActiveStorage URL into the video service.
11
+ # Should set video.key and video.playback_urls on success.
12
+ def ingest!(video)
13
+ raise NotImplementedError, "#{self.class}#ingest! must be implemented"
14
+ end
15
+
16
+ # Delete a video from the video service.
17
+ def delete!(video)
18
+ raise NotImplementedError, "#{self.class}#delete! must be implemented"
19
+ end
20
+
21
+ # Return the playback URL for a video.
22
+ def playback_url(video, signed: false, expiration: 4.hours)
23
+ raise NotImplementedError, "#{self.class}#playback_url must be implemented"
24
+ end
25
+
26
+ # Return an HTML player component for the video.
27
+ def player_component(video, muted: false, expiration: 4.hours)
28
+ raise NotImplementedError, "#{self.class}#player_component must be implemented"
29
+ end
30
+
31
+ # Generate a signed token for the video (if supported).
32
+ def signed_token(video, expiration: 4.hours)
33
+ raise NotImplementedError, "#{self.class}#signed_token must be implemented"
34
+ end
35
+
36
+ # Download and store the video thumbnail.
37
+ def download_thumbnail!(video, time: "3s")
38
+ raise NotImplementedError, "#{self.class}#download_thumbnail! must be implemented"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,213 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "jwt"
4
+
5
+ module Helios
6
+ module Videos
7
+ module Processors
8
+ class Cloudflare < Processor
9
+ SIGNING_KEY_CACHE_KEY = "helios_videos_cf_signing_key"
10
+ SIGNING_KEY_ID_CACHE_KEY = "helios_videos_cf_signing_key_id"
11
+
12
+ def ingest!(video)
13
+ return if video.key.present?
14
+ return unless video.video_file.attached?
15
+
16
+ uri = URI("https://api.cloudflare.com/client/v4/accounts/#{account_id}/stream/copy")
17
+ http = Net::HTTP.new(uri.host, uri.port)
18
+ http.use_ssl = true
19
+
20
+ request = Net::HTTP::Post.new(uri.request_uri, {
21
+ "Authorization" => "Bearer #{api_token}",
22
+ "Content-Type" => "application/json"
23
+ })
24
+
25
+ request.body = {
26
+ url: video.video_file.url,
27
+ meta: { name: video.name },
28
+ requireSignedURLs: config.require_signed_urls
29
+ }.to_json
30
+
31
+ response = http.request(request)
32
+ body = JSON.parse(response.body)
33
+
34
+ unless response.is_a?(Net::HTTPSuccess)
35
+ Rails.logger.error("[helios-videos] Cloudflare ingest error: #{response.code} - #{response.body}")
36
+ raise "Cloudflare ingest failed: #{response.code}"
37
+ end
38
+
39
+ video.update!(
40
+ key: body["result"]["uid"],
41
+ playback_urls: body["result"]["playback"],
42
+ requires_signed_urls: config.require_signed_urls
43
+ )
44
+
45
+ Rails.logger.info("[helios-videos] Video #{video.id} ingested into Cloudflare: #{video.key}")
46
+ end
47
+
48
+ def delete!(video)
49
+ return unless video.key.present?
50
+
51
+ uri = URI("https://api.cloudflare.com/client/v4/accounts/#{account_id}/stream/#{video.key}")
52
+ http = Net::HTTP.new(uri.host, uri.port)
53
+ http.use_ssl = true
54
+
55
+ request = Net::HTTP::Delete.new(uri.request_uri, {
56
+ "Authorization" => "Bearer #{api_token}",
57
+ "Content-Type" => "application/json"
58
+ })
59
+
60
+ response = http.request(request)
61
+
62
+ if response.is_a?(Net::HTTPSuccess)
63
+ Rails.logger.info("[helios-videos] Cloudflare video #{video.key} deleted")
64
+ else
65
+ Rails.logger.warn("[helios-videos] Failed to delete Cloudflare video #{video.key}: #{response.code}")
66
+ end
67
+ end
68
+
69
+ def playback_url(video, signed: false, expiration: 4.hours)
70
+ return nil unless video.key.present?
71
+
72
+ if signed || video.requires_signed_urls?
73
+ token = signed_token(video, expiration: expiration)
74
+ subdomain = customer_subdomain(video)
75
+ "https://#{subdomain}.cloudflarestream.com/#{token}/manifest/video.m3u8"
76
+ else
77
+ video.playback_urls&.dig("hls")
78
+ end
79
+ end
80
+
81
+ def player_component(video, muted: false, expiration: 4.hours)
82
+ return "(VIDEO NOT AVAILABLE)" unless video.key.present?
83
+
84
+ video_src = playback_url(video, signed: video.requires_signed_urls?, expiration: expiration)
85
+
86
+ <<~HTML.html_safe
87
+ <video
88
+ id="videojs-#{video.key}"
89
+ class="video-js vjs-default-skin"
90
+ style="width: 100%; height: 100%;"
91
+ controls
92
+ preload="auto"
93
+ data-setup='{}'
94
+ #{"muted" if muted}
95
+ >
96
+ <source src="#{video_src}" type="application/x-mpegURL" />
97
+ Your browser does not support HLS video.
98
+ </video>
99
+ HTML
100
+ end
101
+
102
+ def signed_token(video, expiration: 4.hours)
103
+ key_id, private_key = fetch_signing_key
104
+
105
+ now = Time.now.to_i
106
+ payload = {
107
+ sub: video.key,
108
+ kid: key_id,
109
+ exp: now + expiration.to_i,
110
+ nbf: now - 60
111
+ }
112
+
113
+ JWT.encode(payload, private_key, "RS256", typ: "JWT", kid: key_id)
114
+ end
115
+
116
+ def download_thumbnail!(video, time: "3s")
117
+ return false unless video.key.present?
118
+ return true if video.thumbnail_image.attached?
119
+
120
+ subdomain = customer_subdomain(video)
121
+ return false unless subdomain.present?
122
+
123
+ if video.requires_signed_urls?
124
+ token = signed_token(video, expiration: 1.hour)
125
+ base_url = "https://#{subdomain}.cloudflarestream.com/#{token}/thumbnails/thumbnail.jpg"
126
+ else
127
+ base_url = "https://#{subdomain}.cloudflarestream.com/#{video.key}/thumbnails/thumbnail.jpg"
128
+ end
129
+ url = time.present? ? "#{base_url}?time=#{time}" : base_url
130
+
131
+ require "open-uri"
132
+ downloaded_image = URI.open(url)
133
+ video.thumbnail_image.attach(
134
+ io: downloaded_image,
135
+ filename: "video_#{video.id}_thumbnail.jpg",
136
+ content_type: "image/jpeg"
137
+ )
138
+ Rails.logger.info("[helios-videos] Downloaded thumbnail for video #{video.id}")
139
+ true
140
+ rescue => e
141
+ Rails.logger.error("[helios-videos] Failed to download thumbnail for video #{video.id}: #{e.message}")
142
+ false
143
+ end
144
+
145
+ private
146
+
147
+ def account_id
148
+ config.cloudflare_account_id || raise("CLOUDFLARE_ACCOUNT_ID not configured")
149
+ end
150
+
151
+ def api_token
152
+ config.cloudflare_api_token || raise("CLOUDFLARE_API_TOKEN not configured")
153
+ end
154
+
155
+ def customer_subdomain(video)
156
+ config.cloudflare_customer_subdomain || extract_subdomain_from_video(video)
157
+ end
158
+
159
+ def extract_subdomain_from_video(video)
160
+ return nil unless video.playback_urls.present? && video.playback_urls["hls"].present?
161
+ uri = URI.parse(video.playback_urls["hls"])
162
+ uri.host.split(".").first
163
+ rescue => e
164
+ Rails.logger.error("[helios-videos] Failed to extract subdomain: #{e.message}")
165
+ nil
166
+ end
167
+
168
+ def fetch_signing_key
169
+ cached_key = Rails.cache.read(SIGNING_KEY_CACHE_KEY)
170
+ cached_key_id = Rails.cache.read(SIGNING_KEY_ID_CACHE_KEY)
171
+
172
+ if cached_key.present? && cached_key_id.present?
173
+ begin
174
+ return [cached_key_id, OpenSSL::PKey::RSA.new(cached_key)]
175
+ rescue OpenSSL::PKey::RSAError
176
+ Rails.cache.delete(SIGNING_KEY_CACHE_KEY)
177
+ Rails.cache.delete(SIGNING_KEY_ID_CACHE_KEY)
178
+ end
179
+ end
180
+
181
+ result = create_signing_key
182
+ key_id = result["id"]
183
+ pem = Base64.decode64(result["pem"])
184
+
185
+ Rails.cache.write(SIGNING_KEY_CACHE_KEY, pem, expires_in: 7.days)
186
+ Rails.cache.write(SIGNING_KEY_ID_CACHE_KEY, key_id, expires_in: 7.days)
187
+
188
+ [key_id, OpenSSL::PKey::RSA.new(pem)]
189
+ end
190
+
191
+ def create_signing_key
192
+ uri = URI("https://api.cloudflare.com/client/v4/accounts/#{account_id}/stream/keys")
193
+ http = Net::HTTP.new(uri.host, uri.port)
194
+ http.use_ssl = true
195
+
196
+ request = Net::HTTP::Post.new(uri.request_uri, {
197
+ "Authorization" => "Bearer #{api_token}",
198
+ "Content-Type" => "application/json"
199
+ })
200
+
201
+ response = http.request(request)
202
+ body = JSON.parse(response.body)
203
+
204
+ unless response.is_a?(Net::HTTPSuccess)
205
+ raise "Failed to create Cloudflare signing key: #{response.code} - #{response.body}"
206
+ end
207
+
208
+ body["result"] || raise("Cloudflare API returned no result: #{body.inspect}")
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,109 @@
1
+ module Helios
2
+ module Videos
3
+ module Processors
4
+ class Mux < Processor
5
+ def ingest!(video)
6
+ return if video.key.present?
7
+ return unless video.video_file.attached?
8
+
9
+ require "mux_ruby"
10
+
11
+ MuxRuby.configure do |c|
12
+ c.username = config.mux_token_id
13
+ c.password = config.mux_token_secret
14
+ end
15
+
16
+ assets_api = MuxRuby::AssetsApi.new
17
+ create_request = MuxRuby::CreateAssetRequest.new(
18
+ input: video.video_file.url,
19
+ playback_policy: [MuxRuby::PlaybackPolicy::PUBLIC]
20
+ )
21
+
22
+ response = assets_api.create_asset(create_request)
23
+ asset = response.data
24
+
25
+ video.update!(
26
+ key: asset.id,
27
+ playback_urls: { "hls" => "https://stream.mux.com/#{asset.playback_ids&.first&.id}.m3u8" }
28
+ )
29
+
30
+ Rails.logger.info("[helios-videos] Video #{video.id} ingested into Mux: #{video.key}")
31
+ end
32
+
33
+ def delete!(video)
34
+ return unless video.key.present?
35
+
36
+ require "mux_ruby"
37
+
38
+ MuxRuby.configure do |c|
39
+ c.username = config.mux_token_id
40
+ c.password = config.mux_token_secret
41
+ end
42
+
43
+ assets_api = MuxRuby::AssetsApi.new
44
+ assets_api.delete_asset(video.key)
45
+ Rails.logger.info("[helios-videos] Mux asset #{video.key} deleted")
46
+ rescue MuxRuby::ApiError => e
47
+ Rails.logger.warn("[helios-videos] Failed to delete Mux asset #{video.key}: #{e.message}")
48
+ end
49
+
50
+ def playback_url(video, signed: false, expiration: 4.hours)
51
+ return nil unless video.key.present?
52
+ video.playback_urls&.dig("hls")
53
+ end
54
+
55
+ def player_component(video, muted: false, expiration: 4.hours)
56
+ return "(VIDEO NOT AVAILABLE)" unless video.key.present?
57
+
58
+ playback_id = extract_playback_id(video)
59
+ return "(VIDEO NOT AVAILABLE)" unless playback_id.present?
60
+
61
+ <<~HTML.html_safe
62
+ <mux-player
63
+ stream-type="on-demand"
64
+ playback-id="#{playback_id}"
65
+ controls
66
+ #{"muted" if muted}
67
+ style="width: 100%;"
68
+ ></mux-player>
69
+ HTML
70
+ end
71
+
72
+ def signed_token(video, expiration: 4.hours)
73
+ nil # Mux uses playback IDs, not signed tokens in the same way
74
+ end
75
+
76
+ def download_thumbnail!(video, time: "3s")
77
+ return false unless video.key.present?
78
+ return true if video.thumbnail_image.attached?
79
+
80
+ playback_id = extract_playback_id(video)
81
+ return false unless playback_id.present?
82
+
83
+ url = "https://image.mux.com/#{playback_id}/thumbnail.jpg?time=#{time.to_i}"
84
+
85
+ require "open-uri"
86
+ downloaded_image = URI.open(url)
87
+ video.thumbnail_image.attach(
88
+ io: downloaded_image,
89
+ filename: "video_#{video.id}_thumbnail.jpg",
90
+ content_type: "image/jpeg"
91
+ )
92
+ true
93
+ rescue => e
94
+ Rails.logger.error("[helios-videos] Failed to download Mux thumbnail for video #{video.id}: #{e.message}")
95
+ false
96
+ end
97
+
98
+ private
99
+
100
+ def extract_playback_id(video)
101
+ hls_url = video.playback_urls&.dig("hls")
102
+ return nil unless hls_url.present?
103
+ # Extract from https://stream.mux.com/PLAYBACK_ID.m3u8
104
+ hls_url.match(%r{stream\.mux\.com/([^.]+)\.m3u8})&.captures&.first
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,5 @@
1
+ module Helios
2
+ module Videos
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,33 @@
1
+ require "helios/videos/version"
2
+ require "helios/videos/engine"
3
+ require "helios/videos/configuration"
4
+ require "helios/videos/processor"
5
+ require "helios/videos/processors/cloudflare"
6
+ require "helios/videos/processors/mux"
7
+
8
+ module Helios
9
+ module Videos
10
+ class << self
11
+ def configuration
12
+ @configuration ||= Configuration.new
13
+ end
14
+
15
+ def configure
16
+ yield(configuration)
17
+ end
18
+
19
+ def processor
20
+ @processor = nil if @processor_type != configuration.processor
21
+ @processor_type = configuration.processor
22
+ @processor ||= case configuration.processor
23
+ when :cloudflare
24
+ Processors::Cloudflare.new(configuration)
25
+ when :mux
26
+ Processors::Mux.new(configuration)
27
+ else
28
+ raise "Unknown video processor: #{configuration.processor}. Use :cloudflare or :mux"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :helios_videos do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: helios-videos
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jason Fleetwood-Boldt
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: jwt
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ description: Upload videos to S3 via ActiveStorage direct upload, ingest into Mux
41
+ or Cloudflare Stream for processing, and serve streaming video back to users.
42
+ email:
43
+ - jason@heliosflow.ai
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - MIT-LICENSE
49
+ - README.md
50
+ - Rakefile
51
+ - app/assets/stylesheets/helios/videos/application.css
52
+ - app/controllers/helios/videos/admin/base_controller.rb
53
+ - app/controllers/helios/videos/admin/videos_controller.rb
54
+ - app/controllers/helios/videos/application_controller.rb
55
+ - app/helpers/helios/videos/application_helper.rb
56
+ - app/javascript/helios/videos/controllers/video_block_controller.js
57
+ - app/javascript/helios/videos/index.js
58
+ - app/jobs/helios/videos/application_job.rb
59
+ - app/jobs/helios/videos/check_video_job.rb
60
+ - app/mailers/helios/videos/application_mailer.rb
61
+ - app/models/helios/videos/application_record.rb
62
+ - app/models/helios/videos/video.rb
63
+ - app/views/layouts/helios/videos/application.html.erb
64
+ - config/routes.rb
65
+ - db/migrate/20250510000001_create_helios_videos_videos.rb
66
+ - lib/helios/videos.rb
67
+ - lib/helios/videos/configuration.rb
68
+ - lib/helios/videos/engine.rb
69
+ - lib/helios/videos/processor.rb
70
+ - lib/helios/videos/processors/cloudflare.rb
71
+ - lib/helios/videos/processors/mux.rb
72
+ - lib/helios/videos/version.rb
73
+ - lib/tasks/helios/videos_tasks.rake
74
+ homepage: https://github.com/heliosdev/helios-videos
75
+ licenses:
76
+ - Nonstandard
77
+ metadata:
78
+ homepage_uri: https://github.com/heliosdev/helios-videos
79
+ source_code_uri: https://github.com/heliosdev/helios-videos
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.6.9
95
+ specification_version: 4
96
+ summary: Video upload, processing, and streaming for Rails via Mux or Cloudflare Stream
97
+ test_files: []