conversant 1.0.16

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.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/.env.example +39 -0
  3. data/.gitignore +52 -0
  4. data/.gitlab-ci.yml +108 -0
  5. data/.rspec +3 -0
  6. data/.rubocop.yml +16 -0
  7. data/.yardopts +7 -0
  8. data/CHANGELOG.md +487 -0
  9. data/Gemfile +12 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +860 -0
  12. data/RELEASE.md +726 -0
  13. data/Rakefile +21 -0
  14. data/conversant.gemspec +49 -0
  15. data/examples/inheritance_integration.rb +348 -0
  16. data/examples/rails_initializer.rb +69 -0
  17. data/lib/conversant/configuration.rb +132 -0
  18. data/lib/conversant/v3/base.rb +47 -0
  19. data/lib/conversant/v3/http_client.rb +456 -0
  20. data/lib/conversant/v3/mixins/authentication.rb +221 -0
  21. data/lib/conversant/v3/services/authorization.rb +194 -0
  22. data/lib/conversant/v3/services/cdn/analytics.rb +483 -0
  23. data/lib/conversant/v3/services/cdn/audit.rb +71 -0
  24. data/lib/conversant/v3/services/cdn/business.rb +122 -0
  25. data/lib/conversant/v3/services/cdn/certificate.rb +180 -0
  26. data/lib/conversant/v3/services/cdn/dashboard.rb +109 -0
  27. data/lib/conversant/v3/services/cdn/domain.rb +223 -0
  28. data/lib/conversant/v3/services/cdn/monitoring.rb +65 -0
  29. data/lib/conversant/v3/services/cdn/partner/analytics.rb +233 -0
  30. data/lib/conversant/v3/services/cdn/partner.rb +60 -0
  31. data/lib/conversant/v3/services/cdn.rb +221 -0
  32. data/lib/conversant/v3/services/lms/dashboard.rb +99 -0
  33. data/lib/conversant/v3/services/lms/domain.rb +108 -0
  34. data/lib/conversant/v3/services/lms/job.rb +211 -0
  35. data/lib/conversant/v3/services/lms/partner/analytics.rb +266 -0
  36. data/lib/conversant/v3/services/lms/partner/business.rb +151 -0
  37. data/lib/conversant/v3/services/lms/partner/report.rb +170 -0
  38. data/lib/conversant/v3/services/lms/partner.rb +58 -0
  39. data/lib/conversant/v3/services/lms/preset.rb +57 -0
  40. data/lib/conversant/v3/services/lms.rb +173 -0
  41. data/lib/conversant/v3/services/oss/partner/analytics.rb +105 -0
  42. data/lib/conversant/v3/services/oss/partner.rb +48 -0
  43. data/lib/conversant/v3/services/oss.rb +128 -0
  44. data/lib/conversant/v3/services/portal/dashboard.rb +114 -0
  45. data/lib/conversant/v3/services/portal.rb +219 -0
  46. data/lib/conversant/v3/services/vms/analytics.rb +114 -0
  47. data/lib/conversant/v3/services/vms/business.rb +190 -0
  48. data/lib/conversant/v3/services/vms/partner/analytics.rb +133 -0
  49. data/lib/conversant/v3/services/vms/partner/business.rb +90 -0
  50. data/lib/conversant/v3/services/vms/partner.rb +57 -0
  51. data/lib/conversant/v3/services/vms/transcoding.rb +184 -0
  52. data/lib/conversant/v3/services/vms.rb +166 -0
  53. data/lib/conversant/v3.rb +36 -0
  54. data/lib/conversant/version.rb +5 -0
  55. data/lib/conversant.rb +108 -0
  56. data/publish.sh +107 -0
  57. data/sig/conversant/v3/services/authorization.rbs +34 -0
  58. data/sig/conversant/v3/services/cdn.rbs +123 -0
  59. data/sig/conversant/v3/services/lms.rbs +80 -0
  60. data/sig/conversant/v3/services/portal.rbs +22 -0
  61. data/sig/conversant/v3/services/vms.rbs +64 -0
  62. data/sig/conversant/v3.rbs +85 -0
  63. data/sig/conversant.rbs +37 -0
  64. metadata +267 -0
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'authorization'
4
+
5
+ module Conversant
6
+ module V3
7
+ module Services
8
+ # CDN service client for Conversant Content Delivery Network
9
+ #
10
+ # Provides comprehensive access to CDN functionality including:
11
+ # - Analytics and reporting (bandwidth, volume, viewers, RPS)
12
+ # - Domain management and configuration
13
+ # - SSL certificate management
14
+ # - Business metrics and monitoring
15
+ # - Audit logging and compliance
16
+ #
17
+ # @example Basic usage
18
+ # cdn = Conversant::V3.cdn(customer_id)
19
+ #
20
+ # # Get bandwidth analytics
21
+ # bandwidth = cdn.analytics.bandwidths({
22
+ # domain: "All",
23
+ # startTime: "2025-01-01T00:00:00Z",
24
+ # endTime: "2025-01-31T23:59:59Z",
25
+ # interval: "hour"
26
+ # })
27
+ #
28
+ # # Manage domains
29
+ # domains = cdn.domain.all
30
+ # new_domain = cdn.domain.create(domain_config)
31
+ #
32
+ # # Business metrics
33
+ # traffic = cdn.business.bandwidth(time_range)
34
+ #
35
+ # @since 1.0.0
36
+ class CDN < ::Conversant::V3::Base
37
+ include Authorization
38
+
39
+ # Get analytics service instance
40
+ #
41
+ # @return [Analytics] analytics service for CDN metrics and reporting
42
+ # @since 1.0.0
43
+ def analytics
44
+ @analytics ||= Analytics.new(self)
45
+ end
46
+
47
+ # Get monitoring service instance
48
+ #
49
+ # @return [Monitoring] monitoring service for real-time CDN performance
50
+ # @since 1.0.0
51
+ def monitoring
52
+ @monitoring ||= Monitoring.new(self)
53
+ end
54
+
55
+ # Get audit service instance
56
+ #
57
+ # @return [Audit] audit service for activity logging and compliance
58
+ # @since 1.0.0
59
+ def audit
60
+ @audit ||= Audit.new(self)
61
+ end
62
+
63
+ # Get dashboard service instance
64
+ #
65
+ # @return [Dashboard] dashboard service for quick daily metrics
66
+ # @since 1.0.8
67
+ def dashboard
68
+ @dashboard ||= Dashboard.new(self)
69
+ end
70
+
71
+ # Get domain management service instance
72
+ #
73
+ # @return [Domain] domain service for CDN domain management
74
+ # @since 1.0.1
75
+ def domain
76
+ @domain ||= Domain.new(self)
77
+ end
78
+
79
+ # Get business metrics service instance
80
+ #
81
+ # @return [Business] business service for billing and usage metrics
82
+ # @since 1.0.1
83
+ def business
84
+ @business ||= Business.new(self)
85
+ end
86
+
87
+ # Get certificate management service instance
88
+ #
89
+ # @return [Certificate] certificate service for SSL/TLS management
90
+ # @since 1.0.1
91
+ def certificate
92
+ @certificate ||= Certificate.new(self)
93
+ end
94
+
95
+ # Get partner service instance
96
+ #
97
+ # Provides access to partner-level analytics and reporting that aggregate
98
+ # data across multiple customer accounts.
99
+ #
100
+ # @return [Partner] partner service for CDN and OSS analytics
101
+ # @since 1.0.12
102
+ #
103
+ # @example Access partner analytics
104
+ # cdn = Conversant::V3.cdn(12345)
105
+ # bandwidth = cdn.partner.analytics.bandwidth(payload)
106
+ # storage = cdn.partner.oss.storage_usage(payload)
107
+ def partner
108
+ @partner ||= Partner.new(self)
109
+ end
110
+
111
+ # @deprecated Use {#partner} instead
112
+ # Get partner analytics service instance (deprecated)
113
+ #
114
+ # @return [CDN::Partner::Analytics] partner analytics for CDN reporting
115
+ # @since 1.0.8
116
+ # @deprecated Use `cdn.partner.analytics` instead of `cdn.partner_analytics`
117
+ def partner_analytics
118
+ @partner_analytics ||= Partner::Analytics.new(self)
119
+ end
120
+
121
+ # @deprecated Use {Conversant::V3.oss} instead
122
+ # Get partner OSS analytics service instance (deprecated)
123
+ #
124
+ # @return [Conversant::V3::Services::OSS::Partner::Analytics] partner analytics for OSS storage reporting
125
+ # @since 1.0.8
126
+ # @deprecated Use `oss = Conversant::V3.oss(customer_id); oss.partner.analytics` instead of `cdn.partner_oss`
127
+ def partner_oss
128
+ @partner_oss ||= Conversant::V3::Services::OSS::Partner::Analytics.new(self)
129
+ end
130
+
131
+ protected
132
+
133
+ def call(method, uri, payload = nil)
134
+ url = "#{configuration.private_cdn_endpoint}#{uri}"
135
+
136
+ headers = authorized_headers
137
+
138
+ # Check for SESSION in Cookie header
139
+ if headers['Cookie'].nil? || !headers['Cookie'].include?('SESSION=')
140
+ logger.error "#{identifier}.METHOD:call.NO_SESSION"
141
+ raise AuthenticationError, 'Missing SESSION for CDN'
142
+ end
143
+
144
+ code, response = request(method, url, payload, headers)
145
+
146
+ if code >= 400
147
+ logger.error "#{identifier}.METHOD:call.HTTP_ERROR:#{code}"
148
+ raise ApiError, "CDN API error: #{code}"
149
+ end
150
+
151
+ response.body
152
+ end
153
+
154
+ private
155
+
156
+ def fetch_new_session
157
+ sessions = authenticate
158
+ return nil unless sessions && sessions[:session] && sessions[:sso_gw_session2]
159
+
160
+ signature_url = "#{configuration.private_cdn_endpoint}/partner-dashboard?customerId=#{customer_id}"
161
+ logger.debug "#{identifier}.METHOD:authorize.REQUESTING_SESSION"
162
+
163
+ response = RestClient.get(
164
+ signature_url,
165
+ {
166
+ authority: URI.parse(configuration.private_cdn_endpoint).hostname,
167
+ referer: portal_endpoint,
168
+ user_agent: configuration.default_ua,
169
+ cookies: {
170
+ 'SESSION': sessions[:session],
171
+ 'SSO_GW_SESSION2': sessions[:sso_gw_session2]
172
+ },
173
+ timeout: 30,
174
+ open_timeout: 30
175
+ }
176
+ )
177
+
178
+ if response.cookies['SESSION']
179
+ session_cookie = response.cookies['SESSION']
180
+ redis.set(session_cache_key, session_cookie, ex: configuration.cache_ttl)
181
+ logger.debug "#{identifier}.METHOD:authorize.SESSION_OBTAINED"
182
+ session_cookie
183
+ else
184
+ logger.error "#{identifier}.METHOD:authorize.NO_SESSION_IN_RESPONSE"
185
+ nil
186
+ end
187
+ rescue RestClient::Unauthorized, RestClient::Forbidden => e
188
+ logger.error "#{identifier}.METHOD:authorize.AUTH_ERROR:#{e.message}"
189
+ nil
190
+ rescue StandardError => e
191
+ logger.error "#{identifier}.METHOD:authorize.ERROR:#{e.message}"
192
+ nil
193
+ end
194
+
195
+ protected
196
+
197
+ def service_endpoint
198
+ configuration.private_cdn_endpoint
199
+ end
200
+
201
+ def session_cookie_name
202
+ 'SESSION'
203
+ end
204
+
205
+ def requires_session?
206
+ true
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
212
+
213
+ # Load nested service classes after CDN is defined
214
+ require_relative 'cdn/analytics'
215
+ require_relative 'cdn/monitoring'
216
+ require_relative 'cdn/audit'
217
+ require_relative 'cdn/dashboard'
218
+ require_relative 'cdn/domain'
219
+ require_relative 'cdn/business'
220
+ require_relative 'cdn/certificate'
221
+ require_relative 'cdn/partner'
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Conversant
4
+ module V3
5
+ module Services
6
+ class LMS
7
+ class Dashboard
8
+ attr_reader :parent
9
+
10
+ def initialize(parent)
11
+ @parent = parent
12
+ end
13
+
14
+ def live
15
+ payload = { _: (Time.now.to_f * 1000).to_i }
16
+ response = @parent.send(:call, 'GET', "/v4/live/duration?#{payload.to_query}")
17
+ return nil if response.nil?
18
+
19
+ JSON.parse(response)
20
+ rescue StandardError => e
21
+ logger.error "#{@parent.send(:identifier)}.METHOD:#{__method__}.EXCEPTION:#{e.message}"
22
+ nil
23
+ end
24
+
25
+ def vod
26
+ payload = { _: (Time.now.to_f * 1000).to_i }
27
+ response = @parent.send(:call, 'GET', "/v4/live/duration?#{payload.to_query}")
28
+ return nil if response.nil?
29
+
30
+ JSON.parse(response)
31
+ rescue StandardError => e
32
+ logger.error "#{@parent.send(:identifier)}.METHOD:#{__method__}.EXCEPTION:#{e.message}"
33
+ nil
34
+ end
35
+
36
+ def dvr
37
+ payload = { _: (Time.now.to_f * 1000).to_i }
38
+ response = @parent.send(:call, 'GET', "/v4/dvr/duration?#{payload.to_query}")
39
+ return nil if response.nil?
40
+
41
+ JSON.parse(response)
42
+ rescue StandardError => e
43
+ logger.error "#{@parent.send(:identifier)}.METHOD:#{__method__}.EXCEPTION:#{e.message}"
44
+ nil
45
+ end
46
+
47
+ def recent_jobs
48
+ response = @parent.send(:call, 'GET', '/dashboard/dashboard')
49
+ return [] if response.nil?
50
+
51
+ # Parse HTML response to extract job data
52
+ require 'nokogiri' if defined?(Nokogiri)
53
+ return [] unless defined?(Nokogiri)
54
+
55
+ doc = Nokogiri::HTML.parse(response)
56
+ table = doc.at('table')
57
+ return [] if table.nil?
58
+
59
+ jobs = table.search('tr').map do |row|
60
+ cells = row.search('td')
61
+ next [] if cells.length.zero?
62
+
63
+ app_name, stream_name, type, created, started, recording, status = cells
64
+
65
+ {
66
+ app_name: app_name&.text&.strip,
67
+ stream_name: stream_name&.text&.strip,
68
+ type: type&.text&.strip,
69
+ created: parse_timestamp(created&.text),
70
+ started: parse_timestamp(started&.text),
71
+ recording: recording&.text&.strip,
72
+ status: status&.text&.strip
73
+ }
74
+ end.flatten.compact
75
+
76
+ jobs
77
+ rescue StandardError => e
78
+ logger.error "#{@parent.send(:identifier)}.METHOD:#{__method__}.EXCEPTION:#{e.message}"
79
+ []
80
+ end
81
+
82
+ private
83
+
84
+ def parse_timestamp(timestamp_text)
85
+ return nil unless timestamp_text
86
+
87
+ Time.at(timestamp_text.to_i / 1000)
88
+ rescue StandardError
89
+ nil
90
+ end
91
+
92
+ def logger
93
+ @parent.send(:logger)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Conversant
4
+ module V3
5
+ module Services
6
+ class LMS
7
+ class Domain
8
+ attr_reader :parent
9
+
10
+ def initialize(parent)
11
+ @parent = parent
12
+ end
13
+
14
+ def all(params = {})
15
+ default_payload = {
16
+ sEcho: 1,
17
+ iColumns: 1,
18
+ sColumns: nil,
19
+ iDisplayStart: 0,
20
+ iDisplayLength: 20,
21
+ mDataProp_0: 'domain',
22
+ mDataProp_1: 'app',
23
+ mDataProp_2: 'stream',
24
+ mDataProp_3: 'created',
25
+ mDataProp_4: 'id',
26
+ iSortingCols: 0,
27
+ bSortable_0: 'false',
28
+ bSortable_1: 'false',
29
+ bSortable_2: 'false',
30
+ bSortable_3: 'false',
31
+ bSortable_4: 'false',
32
+ page_number: 1,
33
+ page_size: 20,
34
+ sort_field: nil,
35
+ sort_type: nil,
36
+ _: (Time.now.to_f * 1000).to_i
37
+ }
38
+
39
+ merged_payload = default_payload.merge(params)
40
+ response = @parent.send(:call, 'GET', "/live/domain?#{merged_payload.to_query}")
41
+ return nil if response.nil?
42
+
43
+ response = JSON.parse(response)
44
+ return nil unless response&.[]('list')
45
+
46
+ {
47
+ current_page_number: response['current_page_number'],
48
+ has_next: response['has_next'],
49
+ has_pre: response['has_pre'],
50
+ total_count: response['total_count'],
51
+ total_page_number: response['total_page_number'],
52
+ data: response['list']
53
+ }
54
+ rescue StandardError => e
55
+ logger.error "#{@parent.send(:identifier)}.METHOD:#{__method__}.EXCEPTION:#{e.message}"
56
+ nil
57
+ end
58
+
59
+ def show(domain_id)
60
+ response = @parent.send(:call, 'GET', "/live/domain/#{domain_id}")
61
+ return nil if response.nil?
62
+
63
+ JSON.parse(response)
64
+ rescue StandardError => e
65
+ logger.error "#{@parent.send(:identifier)}.METHOD:#{__method__}.EXCEPTION:#{e.message}"
66
+ nil
67
+ end
68
+
69
+ def create(payload)
70
+ response = @parent.send(:call, 'POST', '/live/domain', payload)
71
+ return false if response.nil?
72
+
73
+ JSON.parse(response)&.[]('status') == 'ok'
74
+ rescue StandardError => e
75
+ logger.error "#{@parent.send(:identifier)}.METHOD:#{__method__}.EXCEPTION:#{e.message}"
76
+ false
77
+ end
78
+
79
+ def update(payload)
80
+ response = @parent.send(:call, 'PUT', '/live/domain', payload)
81
+ return false if response.nil?
82
+
83
+ JSON.parse(response)&.[]('status') == 'ok'
84
+ rescue StandardError => e
85
+ logger.error "#{@parent.send(:identifier)}.METHOD:#{__method__}.EXCEPTION:#{e.message}"
86
+ false
87
+ end
88
+
89
+ def delete(domain_id)
90
+ response = @parent.send(:call, 'DELETE', "/live/domain/#{domain_id}")
91
+ return false if response.nil?
92
+
93
+ JSON.parse(response)&.[]('status') == 'ok'
94
+ rescue StandardError => e
95
+ logger.error "#{@parent.send(:identifier)}.METHOD:#{__method__}.EXCEPTION:#{e.message}"
96
+ false
97
+ end
98
+
99
+ private
100
+
101
+ def logger
102
+ @parent.send(:logger)
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Conversant
4
+ module V3
5
+ module Services
6
+ class LMS
7
+ # Job management service for live streaming operations
8
+ #
9
+ # Provides comprehensive job management functionality for live streaming including:
10
+ # - Job listing and filtering
11
+ # - Job status tracking
12
+ # - Stream profile information
13
+ # - Manifest URL generation
14
+ #
15
+ # @example Query streaming jobs
16
+ # lms = Conversant::V3.lms(12345)
17
+ #
18
+ # # Get all streaming jobs
19
+ # jobs = lms.job.where(status: 'streaming')
20
+ #
21
+ # jobs.each do |job|
22
+ # puts "Job #{job[:id]}: #{job[:name]} - #{job[:status]}"
23
+ # puts " Manifest: #{job[:manifest]}"
24
+ # puts " Profiles: #{job[:profiles].length}"
25
+ # end
26
+ #
27
+ # @since 1.0.0
28
+ class Job
29
+ # @return [LMS] the parent LMS service instance
30
+ attr_reader :parent
31
+
32
+ # Initialize job service
33
+ #
34
+ # @param parent [LMS] the parent LMS service instance
35
+ def initialize(parent)
36
+ @parent = parent
37
+ end
38
+
39
+ # Query streaming jobs with filters
40
+ #
41
+ # Retrieves a list of streaming jobs matching the specified filters.
42
+ # Returns detailed information about each job including status, profiles,
43
+ # and manifest URLs.
44
+ #
45
+ # @param params [Hash] query parameters
46
+ # @option params [String] :status job status filter ('streaming', 'ready', 'interrupted')
47
+ # @option params [Integer] :limit maximum number of results
48
+ # @option params [Integer] :offset pagination offset
49
+ #
50
+ # @return [Array<Hash>, nil] array of job hashes with details, or nil on error
51
+ # Each hash contains:
52
+ # - :entity [Integer] OSP entity ID
53
+ # - :id [Integer] job ID
54
+ # - :app [String] application name
55
+ # - :name [String] stream name
56
+ # - :status [String] job status ('ready', 'streaming', 'interrupted')
57
+ # - :created_at [DateTime] job creation timestamp
58
+ # - :started_at [DateTime] job start timestamp
59
+ # - :ended_at [DateTime] job end timestamp
60
+ # - :client [String] client IP address
61
+ # - :encoder [String] encoder IP address
62
+ # - :server [String] execution server
63
+ # - :manifest [String] manifest URL
64
+ # - :profiles [Array<Hash>] array of stream profiles
65
+ # - :transcoding [Boolean] transcoding enabled
66
+ # - :gpu [Boolean] GPU acceleration enabled
67
+ #
68
+ # @example Get streaming jobs
69
+ # jobs = lms.job.where(status: 'streaming', limit: 50)
70
+ # jobs.each do |job|
71
+ # puts "#{job[:name]} (#{job[:status]}) - #{job[:manifest]}"
72
+ # end
73
+ #
74
+ # @example Get all jobs
75
+ # all_jobs = lms.job.where
76
+ #
77
+ # @since 1.0.0
78
+ def where(**params)
79
+ response = JSON.parse(@parent.send(:call, 'GET', "/v4/streams?#{params.to_query}"))
80
+ return nil unless response
81
+
82
+ items = response['list'] || response[:list] || []
83
+
84
+ items.map do |item|
85
+ status = item['status']&.to_i || item[:status]&.to_i
86
+ inbound = item['stream_rtmp_inbound'] || item[:stream_rtmp_inbound]
87
+
88
+ profiles = (item['stream_rtmp_outbounds'] || item[:stream_rtmp_outbounds])&.map do |stream|
89
+ {
90
+ preset: stream['preset_name'] || stream[:preset_name],
91
+ type: stream['type'] || stream[:type],
92
+ profile: build_profile_url(inbound, item, stream)
93
+ }
94
+ end
95
+
96
+ {
97
+ entity: item['osp_id'] || item[:osp_id],
98
+ id: item['id'] || item[:id],
99
+ app: item['app'] || item[:app],
100
+ name: item['stream'] || item[:stream],
101
+ status: parse_status(status),
102
+ created_at: parse_timestamp(item['created'] || item[:created]),
103
+ started_at: parse_timestamp(item['started'] || item[:started]),
104
+ ended_at: parse_timestamp(item['ended'] || item[:ended]),
105
+ client: inbound && (inbound['client_ip'] || inbound[:client_ip]),
106
+ encoder: extract_encoder(item),
107
+ server: item['exec_ta'] || item[:exec_ta],
108
+ manifest: build_manifest_url(inbound, item, profiles),
109
+ profiles: profiles || [],
110
+ transcoding: item['transcoding'] || item[:transcoding],
111
+ gpu: (item['gpu'] || item[:gpu]) == 'gpu'
112
+ }
113
+ end
114
+ rescue StandardError => e
115
+ @parent.send(:logger).error "LMS::Job.where error: #{e.message}"
116
+ nil
117
+ end
118
+
119
+ private
120
+
121
+ # Parse numeric status code to string
122
+ #
123
+ # @param status [Integer] numeric status code
124
+ # @return [String, Integer] status string or original code if unknown
125
+ def parse_status(status)
126
+ case status
127
+ when 1 then 'ready'
128
+ when 2 then 'streaming'
129
+ when 12 then 'interrupted'
130
+ else status
131
+ end
132
+ end
133
+
134
+ # Parse Unix timestamp (milliseconds) to DateTime
135
+ #
136
+ # @param timestamp [Integer] Unix timestamp in milliseconds
137
+ # @return [DateTime, nil] parsed DateTime or nil if invalid
138
+ def parse_timestamp(timestamp)
139
+ return nil unless timestamp
140
+
141
+ Time.at(timestamp.to_i / 1000).to_datetime
142
+ rescue StandardError
143
+ nil
144
+ end
145
+
146
+ # Extract encoder IP from stream metadata
147
+ #
148
+ # @param item [Hash] job item hash
149
+ # @return [String] encoder IP address or '-' if not found
150
+ def extract_encoder(item)
151
+ metadata = item['stream_metadata'] || item[:stream_metadata]
152
+ metadata && (metadata['source_ip'] || metadata[:source_ip]) || '-'
153
+ end
154
+
155
+ # Get file extension for stream type
156
+ #
157
+ # @param type [String] stream type ('hls', 'dash')
158
+ # @return [String] file extension with dot
159
+ def stream_extension(type)
160
+ case type
161
+ when 'hls' then '.m3u8'
162
+ when 'dash' then '.mpd'
163
+ else ''
164
+ end
165
+ end
166
+
167
+ # Build profile URL for a stream
168
+ #
169
+ # @param inbound [Hash] inbound stream configuration
170
+ # @param item [Hash] job item hash
171
+ # @param stream [Hash] stream profile configuration
172
+ # @return [String, nil] profile URL or nil if inbound is missing
173
+ def build_profile_url(inbound, item, stream)
174
+ return nil unless inbound
175
+
176
+ domain = inbound['domain'] || inbound[:domain]
177
+ app = item['app'] || item[:app]
178
+ stream_name = item['stream'] || item[:stream]
179
+ preset = stream['preset_name'] || stream[:preset_name]
180
+ type = stream['type'] || stream[:type]
181
+
182
+ "http://#{domain}/#{app}/#{stream_name}-#{preset}#{stream_extension(type)}"
183
+ end
184
+
185
+ # Build manifest URL for a job
186
+ #
187
+ # @param inbound [Hash] inbound stream configuration
188
+ # @param item [Hash] job item hash
189
+ # @param profiles [Array<Hash>] array of stream profiles
190
+ # @return [String, nil] manifest URL or nil if inbound is missing
191
+ def build_manifest_url(inbound, item, profiles)
192
+ return nil unless inbound
193
+
194
+ domain = inbound['domain'] || inbound[:domain]
195
+ app = item['app'] || item[:app]
196
+ manifest_name = item['manifest_name'] || item[:manifest_name]
197
+
198
+ ext = if profiles&.first
199
+ type = profiles.first[:type]
200
+ stream_extension(type)
201
+ else
202
+ ''
203
+ end
204
+
205
+ "http://#{domain}/#{app}/#{manifest_name}#{ext}"
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end