castcaster 0.0.1
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/bin/castcaster +7 -0
- data/lib/castcaster/channel.rb +91 -0
- data/lib/castcaster/channel_cli.rb +105 -0
- data/lib/castcaster/cli.rb +104 -0
- data/lib/castcaster/config.rb +60 -0
- data/lib/castcaster/deploy/compose.rb +76 -0
- data/lib/castcaster/deploy/ffmpeg_services.rb +73 -0
- data/lib/castcaster/deploy/k8s.rb +91 -0
- data/lib/castcaster/deploy/swarm.rb +57 -0
- data/lib/castcaster/deploy/traefik.rb +80 -0
- data/lib/castcaster/deploy.rb +10 -0
- data/lib/castcaster/engines/base.rb +55 -0
- data/lib/castcaster/engines/nginx_rtmp.rb +23 -0
- data/lib/castcaster/engines.rb +17 -0
- data/lib/castcaster/ffmpeg_profiles.rb +16 -0
- data/lib/castcaster/version.rb +3 -0
- data/lib/castcaster.rb +17 -0
- data/templates/deploy/docker-compose.yml.erb +18 -0
- data/templates/deploy/docker-compose.yml.tera +16 -0
- data/templates/deploy/docker-stack.yml.erb +29 -0
- data/templates/deploy/docker-stack.yml.tera +26 -0
- data/templates/deploy/k8s/configmap.yaml.erb +6 -0
- data/templates/deploy/k8s/configmap.yaml.tera +6 -0
- data/templates/deploy/k8s/deployment.yaml.erb +77 -0
- data/templates/deploy/k8s/deployment.yaml.tera +77 -0
- data/templates/deploy/k8s/ingress.yaml.erb +35 -0
- data/templates/deploy/k8s/ingress.yaml.tera +35 -0
- data/templates/deploy/k8s/pvc.yaml.erb +14 -0
- data/templates/deploy/k8s/pvc.yaml.tera +14 -0
- data/templates/deploy/k8s/service.yaml.erb +37 -0
- data/templates/deploy/k8s/service.yaml.tera +37 -0
- data/templates/nginx-rtmp/nginx.conf.erb +110 -0
- metadata +91 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 473f74bfc9f9b8f427068f501fc5db2dc13071234084647028e13f487152dedd
|
|
4
|
+
data.tar.gz: 8566513c993848a0160a91f6c548c1c0dcd1cfdd9a162decb5df4c5e5a0a1122
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7169e96f6ba961b134a96205f43bd023c6ed6a6a1287d57e73e3e78b34fabbb011f19059beb1b27550611a87ac901b51b014a2ef51fdfce99f6d5fd544c21171
|
|
7
|
+
data.tar.gz: a777f05ee638aa1cb76c74edfcf76c1d8d0aa7b1f8cb405ccd4cfe6c34294c41949144ae47b581d26194b31e02759db0b8a180ac4e99bb8e0da211f49d7dd742
|
data/bin/castcaster
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module CastCaster
|
|
2
|
+
class Channel
|
|
3
|
+
STATUSES = %w[stopped live error].freeze
|
|
4
|
+
|
|
5
|
+
attr_reader :name, :path
|
|
6
|
+
attr_accessor :engine, :source, :hls, :transcode, :snapshot, :status
|
|
7
|
+
|
|
8
|
+
def initialize(name, attrs = {})
|
|
9
|
+
@name = name
|
|
10
|
+
@engine = attrs['engine'] || 'default'
|
|
11
|
+
@source = attrs['source'] || {}
|
|
12
|
+
@hls = attrs['hls'] || { 'fragment' => 6, 'playlist_length' => 60 }
|
|
13
|
+
@transcode = attrs['transcode']
|
|
14
|
+
@snapshot = attrs['snapshot']
|
|
15
|
+
@status = attrs['status'] || 'stopped'
|
|
16
|
+
@path = File.join(self.class.channels_dir, "#{name}.yml")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_h
|
|
20
|
+
{
|
|
21
|
+
'name' => @name,
|
|
22
|
+
'engine' => @engine,
|
|
23
|
+
'source' => @source,
|
|
24
|
+
'hls' => @hls,
|
|
25
|
+
'transcode' => @transcode,
|
|
26
|
+
'snapshot' => @snapshot,
|
|
27
|
+
'status' => @status
|
|
28
|
+
}.compact
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def save
|
|
32
|
+
File.write(@path, YAML.dump(to_h))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def destroy
|
|
36
|
+
File.delete(@path) if File.exist?(@path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def live?
|
|
40
|
+
@status == 'live'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def validate!
|
|
44
|
+
raise Error, "Channel name required" if @name.nil? || @name.empty?
|
|
45
|
+
raise Error, "Invalid channel name: #{@name}" unless @name.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
def channels_dir
|
|
50
|
+
File.join(Dir.pwd, 'channels')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def all
|
|
54
|
+
Dir[File.join(channels_dir, '*.yml')].map do |f|
|
|
55
|
+
from_file(f)
|
|
56
|
+
end.compact.sort_by(&:name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def find(name)
|
|
60
|
+
path = File.join(channels_dir, "#{name}.yml")
|
|
61
|
+
return nil unless File.exist?(path)
|
|
62
|
+
from_file(path)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def create(name, attrs = {})
|
|
66
|
+
ch = new(name, attrs)
|
|
67
|
+
ch.validate!
|
|
68
|
+
raise Error, "Channel '#{name}' already exists" if File.exist?(ch.path)
|
|
69
|
+
ch.save
|
|
70
|
+
ch
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def from_file(path)
|
|
76
|
+
data = YAML.safe_load(File.read(path))
|
|
77
|
+
unless data
|
|
78
|
+
warn "Warning: failed to parse channel file: #{path}"
|
|
79
|
+
return nil
|
|
80
|
+
end
|
|
81
|
+
ch = new(data['name'], data)
|
|
82
|
+
ch.path = path
|
|
83
|
+
ch
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def path=(p)
|
|
88
|
+
@path = p
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
module CastCaster
|
|
2
|
+
class ChannelCLI < Thor
|
|
3
|
+
desc 'list', 'List all channels'
|
|
4
|
+
def list
|
|
5
|
+
channels = Channel.all
|
|
6
|
+
if channels.empty?
|
|
7
|
+
say 'No channels. Use `castcaster channel add --name NAME` to create one.'
|
|
8
|
+
return
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
shell = Thor::Shell::Basic.new
|
|
12
|
+
rows = channels.map do |ch|
|
|
13
|
+
[ch.name, ch.engine, ch.live? ? 'live' : 'stopped', ch.source.fetch('url', '-')]
|
|
14
|
+
end
|
|
15
|
+
shell.print_table(rows, headings: %w[Name Engine Status Source])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc 'add', 'Add a new channel'
|
|
19
|
+
option :name, type: :string, required: true, desc: 'Channel name (a-z, 0-9, -, _)'
|
|
20
|
+
option :engine, type: :string, desc: 'Engine override'
|
|
21
|
+
option :source, type: :string, desc: 'Stream source URL'
|
|
22
|
+
option :source_type, type: :string, default: 'test', desc: 'Source type (test / rtmp_pull / rtmp_push / http_pull / srt)'
|
|
23
|
+
option :transcode, type: :string, desc: 'Transcode profiles (e.g. 720p,480p,360p)'
|
|
24
|
+
option :bitrate, type: :string, desc: 'Target bitrate (e.g. 1500k)'
|
|
25
|
+
option :snapshot, type: :numeric, desc: 'Snapshot interval in seconds'
|
|
26
|
+
def add
|
|
27
|
+
attrs = {
|
|
28
|
+
'engine' => options[:engine],
|
|
29
|
+
'source' => { 'type' => options[:source_type], 'url' => options[:source] }.compact,
|
|
30
|
+
'status' => 'stopped'
|
|
31
|
+
}
|
|
32
|
+
attrs['transcode'] = { 'profiles' => options[:transcode], 'bitrate' => options[:bitrate] }.compact if options[:transcode] || options[:bitrate]
|
|
33
|
+
attrs['snapshot'] = options[:snapshot] if options[:snapshot]
|
|
34
|
+
Channel.create(options[:name], attrs)
|
|
35
|
+
say_status :ok, "channel '#{options[:name]}' created"
|
|
36
|
+
say_status :info, "Run `docker compose restart nginx` to apply"
|
|
37
|
+
rescue Error => e
|
|
38
|
+
say_status :error, e.message
|
|
39
|
+
exit 1
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
desc 'rm NAME', 'Remove a channel'
|
|
43
|
+
def rm(name)
|
|
44
|
+
ch = Channel.find(name)
|
|
45
|
+
unless ch
|
|
46
|
+
say_status :error, "channel '#{name}' not found"
|
|
47
|
+
exit 1
|
|
48
|
+
end
|
|
49
|
+
ch.destroy
|
|
50
|
+
say_status :ok, "channel '#{name}' removed"
|
|
51
|
+
say_status :info, "Run `docker compose restart nginx` to apply"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
desc 'update NAME', 'Update channel configuration'
|
|
55
|
+
option :source_type, type: :string, desc: 'Source type (test / rtmp_pull / rtmp_push / http_pull / srt)'
|
|
56
|
+
option :source, type: :string, desc: 'Stream source URL'
|
|
57
|
+
option :transcode, type: :string, desc: 'Transcode profiles (e.g. 720p,480p,360p)'
|
|
58
|
+
option :bitrate, type: :string, desc: 'Target bitrate (e.g. 1500k)'
|
|
59
|
+
option :snapshot, type: :numeric, desc: 'Snapshot interval in seconds (0 to disable)'
|
|
60
|
+
def update(name)
|
|
61
|
+
ch = Channel.find(name)
|
|
62
|
+
unless ch
|
|
63
|
+
say_status :error, "channel '#{name}' not found"
|
|
64
|
+
exit 1
|
|
65
|
+
end
|
|
66
|
+
re_read = YAML.safe_load(File.read(ch.path)) || {}
|
|
67
|
+
re_read['source'] ||= {}
|
|
68
|
+
re_read['source']['type'] = options[:source_type] if options[:source_type]
|
|
69
|
+
re_read['source']['url'] = options[:source] if options[:source]
|
|
70
|
+
if options[:transcode] || options[:bitrate]
|
|
71
|
+
re_read['transcode'] = { 'profiles' => options[:transcode], 'bitrate' => options[:bitrate] }.compact
|
|
72
|
+
end
|
|
73
|
+
if options.key?(:snapshot)
|
|
74
|
+
re_read['snapshot'] = options[:snapshot] > 0 ? options[:snapshot] : nil
|
|
75
|
+
end
|
|
76
|
+
File.write(ch.path, YAML.dump(re_read))
|
|
77
|
+
say_status :ok, "channel '#{name}' updated"
|
|
78
|
+
say_status :info, "Run `docker compose restart nginx` to apply"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
desc 'start NAME', 'Mark a channel as live'
|
|
82
|
+
def start(name)
|
|
83
|
+
ch = Channel.find(name)
|
|
84
|
+
unless ch
|
|
85
|
+
say_status :error, "channel '#{name}' not found"
|
|
86
|
+
exit 1
|
|
87
|
+
end
|
|
88
|
+
ch.status = 'live'
|
|
89
|
+
ch.save
|
|
90
|
+
say_status :ok, "channel '#{name}' marked live"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
desc 'stop NAME', 'Mark a channel as stopped'
|
|
94
|
+
def stop(name)
|
|
95
|
+
ch = Channel.find(name)
|
|
96
|
+
unless ch
|
|
97
|
+
say_status :error, "channel '#{name}' not found"
|
|
98
|
+
exit 1
|
|
99
|
+
end
|
|
100
|
+
ch.status = 'stopped'
|
|
101
|
+
ch.save
|
|
102
|
+
say_status :ok, "channel '#{name}' marked stopped"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
require 'thor'
|
|
2
|
+
|
|
3
|
+
module CastCaster
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
package_name 'castcaster'
|
|
6
|
+
|
|
7
|
+
map %w[--version -v] => :version
|
|
8
|
+
|
|
9
|
+
desc 'init', 'Generate CastCaster config and project structure'
|
|
10
|
+
option :engine, type: :string, aliases: '-e', desc: 'Streaming engine (nginx-rtmp)'
|
|
11
|
+
def init
|
|
12
|
+
config = Config.new
|
|
13
|
+
config.ensure_project_dirs
|
|
14
|
+
token = config.ensure_token
|
|
15
|
+
|
|
16
|
+
cfg = Config::DEFAULTS.dup
|
|
17
|
+
cfg['engine'] = options[:engine] if options[:engine]
|
|
18
|
+
cfg['project_dir'] = Dir.pwd
|
|
19
|
+
config.save(cfg)
|
|
20
|
+
|
|
21
|
+
say_status :created, "castcaster.yml"
|
|
22
|
+
say_status :created, "castcaster.token"
|
|
23
|
+
say_status :created, "channels/"
|
|
24
|
+
say_status :created, "hls/"
|
|
25
|
+
say ''
|
|
26
|
+
say " CastCaster v#{VERSION} initialized"
|
|
27
|
+
say " Engine: #{cfg['engine']}"
|
|
28
|
+
say ''
|
|
29
|
+
say " Edit channels/, then run:"
|
|
30
|
+
say " docker compose up -d"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
desc 'config', 'Preview generated nginx config'
|
|
34
|
+
option :engine, type: :string, aliases: '-e', desc: 'Override engine'
|
|
35
|
+
def config
|
|
36
|
+
cfg = Config.new.load
|
|
37
|
+
engine_name = options[:engine] || cfg['engine']
|
|
38
|
+
engine = Engines.create(engine_name, cfg)
|
|
39
|
+
say engine.config_preview
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
desc 'deploy', 'Generate compose.yml'
|
|
43
|
+
option :engine, type: :string, aliases: '-e', desc: 'Override engine'
|
|
44
|
+
option :mode, type: :string, aliases: '-m', default: 'compose', desc: 'compose | swarm | k8s'
|
|
45
|
+
option :with_traefik, type: :boolean, desc: 'Include Traefik'
|
|
46
|
+
def deploy
|
|
47
|
+
cfg = Config.new.load
|
|
48
|
+
cfg['enable_traefik'] = true if options[:with_traefik]
|
|
49
|
+
engine = create_engine
|
|
50
|
+
channels = Channel.all
|
|
51
|
+
case options[:mode]
|
|
52
|
+
when 'swarm'
|
|
53
|
+
dpl = Deploy::Swarm.new(engine, channels, cfg)
|
|
54
|
+
when 'k8s'
|
|
55
|
+
dpl = Deploy::K8s.new(engine, channels, cfg)
|
|
56
|
+
else
|
|
57
|
+
dpl = Deploy::Compose.new(engine, channels)
|
|
58
|
+
dpl.with_traefik = true if options[:with_traefik]
|
|
59
|
+
end
|
|
60
|
+
path = dpl.write
|
|
61
|
+
say_status :created, path
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
IMAGES = {
|
|
65
|
+
'nginx' => 'ghcr.io/lax/castcaster-nginx',
|
|
66
|
+
'ffmpeg' => 'ghcr.io/lax/castcaster-ffmpeg',
|
|
67
|
+
'webui' => 'ghcr.io/lax/castcaster-webui'
|
|
68
|
+
}.freeze
|
|
69
|
+
|
|
70
|
+
desc 'doctor', 'Check system environment'
|
|
71
|
+
def doctor
|
|
72
|
+
checks = {
|
|
73
|
+
'Ruby' => RUBY_VERSION,
|
|
74
|
+
'Docker' => system('docker --version >/dev/null 2>&1') ? `docker --version`.strip : 'not found',
|
|
75
|
+
'Docker Compose'=> system('docker compose version >/dev/null 2>&1') ? `docker compose version`.strip : 'not found',
|
|
76
|
+
'config.yml' => File.exist?('castcaster.yml') ? 'ok' : 'not found (run init)'
|
|
77
|
+
}
|
|
78
|
+
IMAGES.each do |name, tag|
|
|
79
|
+
if system("docker image inspect #{tag} >/dev/null 2>&1")
|
|
80
|
+
checks[name] = 'available'
|
|
81
|
+
else
|
|
82
|
+
checks[name] = "docker pull #{tag}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
checks.each { |name, status| say "#{name.ljust(14)} #{status}" }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
desc 'channel SUBCOMMAND ...', 'Manage channels'
|
|
89
|
+
subcommand 'channel', ChannelCLI
|
|
90
|
+
|
|
91
|
+
desc 'version', 'Show version'
|
|
92
|
+
def version
|
|
93
|
+
say VERSION
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def create_engine
|
|
99
|
+
cfg = Config.new.load
|
|
100
|
+
engine_name = options[:engine] || cfg['engine']
|
|
101
|
+
Engines.create(engine_name, cfg)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module CastCaster
|
|
2
|
+
class Config
|
|
3
|
+
DEFAULTS = {
|
|
4
|
+
'engine' => 'nginx-rtmp',
|
|
5
|
+
'hls_fragment' => 6,
|
|
6
|
+
'hls_window' => 60,
|
|
7
|
+
'domain' => '',
|
|
8
|
+
'acme_email' => 'admin@example.com',
|
|
9
|
+
'enable_traefik' => false
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(path = nil)
|
|
13
|
+
@path = path || default_config_path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def load
|
|
17
|
+
return DEFAULTS.dup unless File.exist?(@path)
|
|
18
|
+
cfg = YAML.safe_load(File.read(@path)) || {}
|
|
19
|
+
DEFAULTS.merge(cfg)
|
|
20
|
+
rescue => e
|
|
21
|
+
raise Error, "Config corrupted: #{e.message}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def save(data)
|
|
25
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
26
|
+
File.write(@path, YAML.dump(data))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ensure_project_dirs
|
|
30
|
+
project_dir = Dir.pwd
|
|
31
|
+
FileUtils.mkdir_p(File.join(project_dir, 'channels'))
|
|
32
|
+
FileUtils.mkdir_p(File.join(project_dir, 'hls'))
|
|
33
|
+
FileUtils.mkdir_p(File.join(project_dir, 'nginx'))
|
|
34
|
+
FileUtils.mkdir_p(File.join(project_dir, 'logs'))
|
|
35
|
+
FileUtils.mkdir_p(File.join(project_dir, 'data'))
|
|
36
|
+
project_dir
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ensure_token
|
|
40
|
+
path = File.join(Dir.pwd, 'castcaster.token')
|
|
41
|
+
return File.read(path).strip if File.exist?(path)
|
|
42
|
+
token = SecureRandom.hex(32)
|
|
43
|
+
File.write(path, token)
|
|
44
|
+
File.chmod(0600, path)
|
|
45
|
+
token
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def load_token
|
|
49
|
+
path = File.join(Dir.pwd, 'castcaster.token')
|
|
50
|
+
return nil unless File.exist?(path)
|
|
51
|
+
File.read(path).strip
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def default_config_path
|
|
57
|
+
File.join(Dir.pwd, 'castcaster.yml')
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module CastCaster
|
|
2
|
+
module Deploy
|
|
3
|
+
class Compose
|
|
4
|
+
include FFmpegServices
|
|
5
|
+
attr_writer :with_traefik
|
|
6
|
+
|
|
7
|
+
def initialize(engine, channels)
|
|
8
|
+
@engine = engine
|
|
9
|
+
@channels = channels
|
|
10
|
+
@cfg = Config.new.load
|
|
11
|
+
@with_traefik = false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def write
|
|
15
|
+
File.write(compose_file, render)
|
|
16
|
+
compose_file
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def compose_file
|
|
20
|
+
File.join(project_dir, 'compose.yml')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def project_dir
|
|
26
|
+
@cfg.fetch('project_dir', Dir.pwd)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def render
|
|
30
|
+
enable_traefik = @with_traefik || @cfg.fetch('enable_traefik', false)
|
|
31
|
+
nginx_ports = ['1935:1935']
|
|
32
|
+
nginx_ports << "#{@cfg.fetch('web_port', 8080)}:8080" unless enable_traefik
|
|
33
|
+
|
|
34
|
+
all = {
|
|
35
|
+
'nginx' => {
|
|
36
|
+
'image' => 'ghcr.io/lax/castcaster-nginx',
|
|
37
|
+
'restart' => 'unless-stopped',
|
|
38
|
+
'ports' => nginx_ports,
|
|
39
|
+
'volumes' => [
|
|
40
|
+
"./nginx/nginx.conf:/etc/nginx/nginx.conf:ro",
|
|
41
|
+
"./hls:/var/lib/castcaster/hls"
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
'webui' => {
|
|
45
|
+
'image' => 'ghcr.io/lax/castcaster-webui',
|
|
46
|
+
'restart' => 'unless-stopped',
|
|
47
|
+
'volumes' => ["./channels:/channels:rw", "./hls:/var/lib/castcaster/hls:ro"],
|
|
48
|
+
'depends_on' => ['nginx'],
|
|
49
|
+
'environment' => {
|
|
50
|
+
'HLS_DIR' => '/var/lib/castcaster/hls',
|
|
51
|
+
'CHANNELS_DIR' => '/channels'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# FFmpeg services (relative paths for host-side compose)
|
|
57
|
+
all.merge!(build_ffmpeg_services(@channels, './hls'))
|
|
58
|
+
|
|
59
|
+
if enable_traefik
|
|
60
|
+
traefik = Deploy::Traefik.new(@cfg)
|
|
61
|
+
all.merge!(traefik.service_definition)
|
|
62
|
+
FileUtils.mkdir_p(File.join(project_dir, 'traefik'))
|
|
63
|
+
File.write(File.join(project_dir, 'traefik', 'dynamic.yml'), traefik.send(:dynamic_config))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
tpl = File.read(File.expand_path('../../../templates/deploy/docker-compose.yml.erb', __dir__))
|
|
67
|
+
erb = ERB.new(tpl, trim_mode: '-')
|
|
68
|
+
ctx = TemplateContext.new(services: all)
|
|
69
|
+
erb.result(ctx.get_binding)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module CastCaster
|
|
2
|
+
module Deploy
|
|
3
|
+
module FFmpegServices
|
|
4
|
+
def build_ffmpeg_services(channels, hls_dir)
|
|
5
|
+
base = 'rtmp://nginx:1935/live'
|
|
6
|
+
services = {}
|
|
7
|
+
channels.each do |ch|
|
|
8
|
+
src = ch.source
|
|
9
|
+
next unless src && src['type']
|
|
10
|
+
next if src['type'] == 'rtmp_push'
|
|
11
|
+
|
|
12
|
+
name = ch.name
|
|
13
|
+
label = { 'castcaster/channel' => name }
|
|
14
|
+
common = {
|
|
15
|
+
'image' => 'ghcr.io/lax/castcaster-ffmpeg',
|
|
16
|
+
'restart' => 'unless-stopped',
|
|
17
|
+
'volumes' => ["#{hls_dir}:/var/lib/castcaster/hls"],
|
|
18
|
+
'depends_on' => ['nginx'],
|
|
19
|
+
'labels' => label,
|
|
20
|
+
'environment' => {
|
|
21
|
+
'CHANNEL_NAME' => name,
|
|
22
|
+
'RTMP_BASE' => base
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
case src['type']
|
|
27
|
+
when 'test'
|
|
28
|
+
services["castcaster-test-#{name}"] = common.merge(
|
|
29
|
+
'command' => ["-c", "teststream.sh"],
|
|
30
|
+
'labels' => label.merge('castcaster/task' => 'test')
|
|
31
|
+
)
|
|
32
|
+
else
|
|
33
|
+
next unless src['url']
|
|
34
|
+
bitrate = (ch.transcode || {}).fetch('bitrate', FFmpegProfiles::RELAY_DEFAULTS['bitrate'])
|
|
35
|
+
profiles = (ch.transcode || {}).fetch('profiles', nil)
|
|
36
|
+
|
|
37
|
+
if profiles
|
|
38
|
+
services["castcaster-adaptive-#{name}"] = common.merge(
|
|
39
|
+
'environment' => common['environment'].merge(
|
|
40
|
+
'SOURCE_URL' => src['url'],
|
|
41
|
+
'PROFILES' => profiles
|
|
42
|
+
),
|
|
43
|
+
'command' => ["-c", "adaptive.sh"],
|
|
44
|
+
'labels' => label.merge('castcaster/task' => 'adaptive')
|
|
45
|
+
)
|
|
46
|
+
else
|
|
47
|
+
services["castcaster-relay-#{name}"] = common.merge(
|
|
48
|
+
'environment' => common['environment'].merge(
|
|
49
|
+
'SOURCE_URL' => src['url'],
|
|
50
|
+
'BITRATE' => bitrate
|
|
51
|
+
),
|
|
52
|
+
'command' => ["-c", "relay.sh"],
|
|
53
|
+
'labels' => label.merge('castcaster/task' => 'relay')
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if ch.snapshot
|
|
58
|
+
services["castcaster-snapshot-#{name}"] = common.merge(
|
|
59
|
+
'environment' => common['environment'].merge(
|
|
60
|
+
'INTERVAL' => ch.snapshot.to_s,
|
|
61
|
+
'SNAPSHOTS_DIR' => '/var/lib/castcaster/snapshots'
|
|
62
|
+
),
|
|
63
|
+
'command' => ["-c", "snapshot.sh"],
|
|
64
|
+
'labels' => label.merge('castcaster/task' => 'snapshot')
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
services
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module CastCaster
|
|
2
|
+
module Deploy
|
|
3
|
+
class K8s
|
|
4
|
+
def initialize(engine, channels, cfg)
|
|
5
|
+
@engine = engine
|
|
6
|
+
@channels = channels
|
|
7
|
+
@cfg = cfg
|
|
8
|
+
@traefik = Deploy::Traefik.new(@cfg) if @cfg.fetch('enable_traefik', false)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def write
|
|
12
|
+
FileUtils.mkdir_p(deploy_dir)
|
|
13
|
+
write_file('deployment.yaml', render_deployment)
|
|
14
|
+
write_file('service.yaml', render_service)
|
|
15
|
+
write_file('configmap.yaml', render_configmap)
|
|
16
|
+
write_file('pvc.yaml', render_pvc)
|
|
17
|
+
write_file('ingress.yaml', render_ingress) if @traefik
|
|
18
|
+
deploy_dir
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def deploy_dir
|
|
22
|
+
File.join(@cfg.fetch('project_dir', Dir.pwd), 'k8s')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def write_file(name, content)
|
|
28
|
+
path = File.join(deploy_dir, name)
|
|
29
|
+
File.write(path, content)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def render_deployment
|
|
33
|
+
tpl = File.read(File.expand_path('../../../templates/deploy/k8s/deployment.yaml.erb', __dir__))
|
|
34
|
+
erb = ERB.new(tpl, trim_mode: '-')
|
|
35
|
+
ctx = TemplateContext.new(
|
|
36
|
+
app: 'castcaster',
|
|
37
|
+
engine_image: 'ghcr.io/lax/castcaster-nginx',
|
|
38
|
+
webui_image: 'ghcr.io/lax/castcaster-webui',
|
|
39
|
+
ffmpeg_image: 'ghcr.io/lax/castcaster-ffmpeg',
|
|
40
|
+
engine_name: @engine.name,
|
|
41
|
+
channels_dir: File.join(@cfg.fetch('project_dir', Dir.pwd), 'channels'),
|
|
42
|
+
hls_dir: @cfg.fetch('hls_dir', '/var/lib/castcaster/hls'),
|
|
43
|
+
web_port: @cfg.fetch('web_port', 9527),
|
|
44
|
+
webui_port: @cfg.fetch('webui_port', 9528)
|
|
45
|
+
)
|
|
46
|
+
erb.result(ctx.get_binding)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def render_service
|
|
50
|
+
tpl = File.read(File.expand_path('../../../templates/deploy/k8s/service.yaml.erb', __dir__))
|
|
51
|
+
erb = ERB.new(tpl, trim_mode: '-')
|
|
52
|
+
ctx = TemplateContext.new(
|
|
53
|
+
app: 'castcaster',
|
|
54
|
+
web_port: @cfg.fetch('web_port', 9527),
|
|
55
|
+
webui_port: @cfg.fetch('webui_port', 9528)
|
|
56
|
+
)
|
|
57
|
+
erb.result(ctx.get_binding)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def render_configmap
|
|
61
|
+
tpl = File.read(File.expand_path('../../../templates/deploy/k8s/configmap.yaml.erb', __dir__))
|
|
62
|
+
erb = ERB.new(tpl, trim_mode: '-')
|
|
63
|
+
ctx = TemplateContext.new(
|
|
64
|
+
app: 'castcaster',
|
|
65
|
+
nginx_conf_path: File.join(@cfg.fetch('project_dir', Dir.pwd), 'nginx', 'nginx.conf'),
|
|
66
|
+
channels_dir: File.join(@cfg.fetch('project_dir', Dir.pwd), 'channels')
|
|
67
|
+
)
|
|
68
|
+
erb.result(ctx.get_binding)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def render_pvc
|
|
72
|
+
tpl = File.read(File.expand_path('../../../templates/deploy/k8s/pvc.yaml.erb', __dir__))
|
|
73
|
+
erb = ERB.new(tpl, trim_mode: '-')
|
|
74
|
+
ctx = TemplateContext.new(app: 'castcaster')
|
|
75
|
+
erb.result(ctx.get_binding)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def render_ingress
|
|
79
|
+
domain = @cfg.fetch('domain', 'stream.example.com')
|
|
80
|
+
tpl = File.read(File.expand_path('../../../templates/deploy/k8s/ingress.yaml.erb', __dir__))
|
|
81
|
+
erb = ERB.new(tpl, trim_mode: '-')
|
|
82
|
+
ctx = TemplateContext.new(
|
|
83
|
+
app: 'castcaster',
|
|
84
|
+
domain: domain,
|
|
85
|
+
acme_email: @cfg.fetch('acme_email', 'admin@example.com')
|
|
86
|
+
)
|
|
87
|
+
erb.result(ctx.get_binding)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module CastCaster
|
|
2
|
+
module Deploy
|
|
3
|
+
class Swarm
|
|
4
|
+
include FFmpegServices
|
|
5
|
+
|
|
6
|
+
def initialize(engine, channels, cfg)
|
|
7
|
+
@engine = engine
|
|
8
|
+
@channels = channels
|
|
9
|
+
@cfg = cfg
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def write
|
|
13
|
+
FileUtils.mkdir_p(deploy_dir)
|
|
14
|
+
File.write(compose_file, render)
|
|
15
|
+
compose_file
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def compose_file
|
|
19
|
+
File.join(deploy_dir, 'docker-stack.yml')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def deploy_dir
|
|
23
|
+
@cfg.fetch('project_dir', Dir.pwd)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def render
|
|
29
|
+
all = {
|
|
30
|
+
'nginx' => {
|
|
31
|
+
'image' => 'ghcr.io/lax/castcaster-nginx',
|
|
32
|
+
'restart' => 'unless-stopped',
|
|
33
|
+
'ports' => ['1935:1935', '8080:8080'],
|
|
34
|
+
'volumes' => ["./nginx/nginx.conf:/etc/nginx/nginx.conf:ro", "./hls:/var/lib/castcaster/hls"]
|
|
35
|
+
},
|
|
36
|
+
'webui' => {
|
|
37
|
+
'image' => 'ghcr.io/lax/castcaster-webui',
|
|
38
|
+
'restart' => 'unless-stopped',
|
|
39
|
+
'volumes' => ["./channels:/channels:rw"],
|
|
40
|
+
'environment' => {'HLS_DIR' => '/var/lib/castcaster/hls', 'CHANNELS_DIR' => '/channels'}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
all.merge!(build_ffmpeg_services(@channels, './hls'))
|
|
45
|
+
|
|
46
|
+
tpl = File.read(File.expand_path('../../../templates/deploy/docker-stack.yml.erb', __dir__))
|
|
47
|
+
erb = ERB.new(tpl, trim_mode: '-')
|
|
48
|
+
ctx = TemplateContext.new(services: all, project: 'castcaster', traefik_public: @cfg.fetch('enable_traefik', false))
|
|
49
|
+
erb.result(ctx.get_binding)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def project_dir
|
|
53
|
+
@cfg.fetch('project_dir', Dir.pwd)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|