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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +84 -0
- data/Rakefile +3 -0
- data/app/assets/stylesheets/helios/videos/application.css +15 -0
- data/app/controllers/helios/videos/admin/base_controller.rb +8 -0
- data/app/controllers/helios/videos/admin/videos_controller.rb +27 -0
- data/app/controllers/helios/videos/application_controller.rb +6 -0
- data/app/helpers/helios/videos/application_helper.rb +6 -0
- data/app/javascript/helios/videos/controllers/video_block_controller.js +47 -0
- data/app/javascript/helios/videos/index.js +4 -0
- data/app/jobs/helios/videos/application_job.rb +6 -0
- data/app/jobs/helios/videos/check_video_job.rb +32 -0
- data/app/mailers/helios/videos/application_mailer.rb +8 -0
- data/app/models/helios/videos/application_record.rb +7 -0
- data/app/models/helios/videos/video.rb +59 -0
- data/app/views/layouts/helios/videos/application.html.erb +17 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20250510000001_create_helios_videos_videos.rb +16 -0
- data/lib/helios/videos/configuration.rb +27 -0
- data/lib/helios/videos/engine.rb +18 -0
- data/lib/helios/videos/processor.rb +42 -0
- data/lib/helios/videos/processors/cloudflare.rb +213 -0
- data/lib/helios/videos/processors/mux.rb +109 -0
- data/lib/helios/videos/version.rb +5 -0
- data/lib/helios/videos.rb +33 -0
- data/lib/tasks/helios/videos_tasks.rake +4 -0
- metadata +97 -0
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,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,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,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,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,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,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,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
|
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: []
|