teems 0.1.0 → 0.2.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 +4 -4
- data/README.md +83 -39
- data/lib/teems/api/client.rb +1 -2
- data/lib/teems/api/meetings.rb +25 -0
- data/lib/teems/cli.rb +5 -1
- data/lib/teems/commands/base.rb +15 -2
- data/lib/teems/commands/help.rb +1 -0
- data/lib/teems/commands/meeting.rb +471 -0
- data/lib/teems/commands/meeting_recording.rb +296 -0
- data/lib/teems/commands/meeting_transcript.rb +241 -0
- data/lib/teems/runner.rb +6 -0
- data/lib/teems/services/api_client.rb +3 -3
- data/lib/teems/services/safari_js_runner.rb +89 -0
- data/lib/teems/version.rb +1 -1
- data/lib/teems.rb +3 -0
- metadata +6 -1
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'meeting_transcript'
|
|
4
|
+
require_relative 'meeting_recording'
|
|
5
|
+
|
|
6
|
+
module Teems
|
|
7
|
+
module Commands
|
|
8
|
+
MEETING_HELP = <<~HELP
|
|
9
|
+
teems meeting - View meeting details, chat, transcripts, and recordings
|
|
10
|
+
|
|
11
|
+
USAGE:
|
|
12
|
+
teems meeting <target> [options]
|
|
13
|
+
|
|
14
|
+
ARGUMENTS:
|
|
15
|
+
target Thread ID (19:meeting_...@thread.v2), calendar event ID,
|
|
16
|
+
or Teams meeting URL
|
|
17
|
+
|
|
18
|
+
OPTIONS:
|
|
19
|
+
--transcript Download meeting transcript (WebVTT)
|
|
20
|
+
--recording Download meeting recording (MP4, requires ffmpeg)
|
|
21
|
+
Combine with --transcript to embed subtitles
|
|
22
|
+
--chat Show meeting chat messages
|
|
23
|
+
-o, --output-dir Directory for downloads (default: current directory)
|
|
24
|
+
-v, --verbose Show debug output
|
|
25
|
+
-q, --quiet Suppress output
|
|
26
|
+
--json Output as JSON
|
|
27
|
+
-h, --help Show this help
|
|
28
|
+
|
|
29
|
+
EXAMPLES:
|
|
30
|
+
teems meeting 19:meeting_abc123@thread.v2
|
|
31
|
+
teems meeting 19:meeting_abc123@thread.v2 --chat
|
|
32
|
+
teems meeting 19:meeting_abc123@thread.v2 --transcript
|
|
33
|
+
teems meeting 19:meeting_abc123@thread.v2 --recording -o ~/Downloads
|
|
34
|
+
teems meeting 19:meeting_abc123@thread.v2 --recording --transcript -o ~/Downloads
|
|
35
|
+
teems meeting AAMkAGVmMDEz... # By calendar event ID
|
|
36
|
+
HELP
|
|
37
|
+
|
|
38
|
+
# Option definitions for the meeting command
|
|
39
|
+
module MeetingOptionDefs
|
|
40
|
+
ALL = {
|
|
41
|
+
'--transcript' => ->(opts, _args) { opts[:transcript] = true },
|
|
42
|
+
'--recording' => ->(opts, _args) { opts[:recording] = true },
|
|
43
|
+
'--chat' => ->(opts, _args) { opts[:chat] = true },
|
|
44
|
+
'-o' => ->(opts, args) { opts[:output_dir] = args.shift },
|
|
45
|
+
'--output-dir' => ->(opts, args) { opts[:output_dir] = args.shift }
|
|
46
|
+
}.freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Resolves meeting targets: thread ID, event ID, or Teams URL
|
|
50
|
+
module MeetingTargetResolver
|
|
51
|
+
MEETING_THREAD_PREFIX = '19:meeting_'
|
|
52
|
+
EVENT_ID_PREFIX = 'AAMk'
|
|
53
|
+
JOIN_URL_PATTERN = %r{/l/meetup-join/([^/?]+)}
|
|
54
|
+
CHAT_URL_PATTERN = %r{/l/chat/([^/?]+)}
|
|
55
|
+
RECAP_PARAMS = %w[callId organizerId tenantId iCalUid driveId driveItemId fileUrl].freeze
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def resolve_meeting_target
|
|
60
|
+
raw = positional_args.first
|
|
61
|
+
return missing_target_error unless raw
|
|
62
|
+
|
|
63
|
+
resolve_raw_target(raw)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def resolve_raw_target(raw)
|
|
67
|
+
if raw.start_with?('https://')
|
|
68
|
+
resolve_url_target(raw)
|
|
69
|
+
elsif raw.start_with?(EVENT_ID_PREFIX)
|
|
70
|
+
resolve_event_target(raw)
|
|
71
|
+
else
|
|
72
|
+
{ thread_id: raw }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def resolve_url_target(url)
|
|
77
|
+
thread_id = extract_meeting_thread(url)
|
|
78
|
+
return build_url_target(url, thread_id) if thread_id
|
|
79
|
+
|
|
80
|
+
parsed = Services::TeamsUrlParser.parse(url)
|
|
81
|
+
return { thread_id: parsed.conversation_id } if parsed
|
|
82
|
+
|
|
83
|
+
error('Could not parse meeting URL')
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_url_target(url, thread_id)
|
|
88
|
+
target = { thread_id: thread_id }
|
|
89
|
+
query = URI.parse(url).query
|
|
90
|
+
return target unless query
|
|
91
|
+
|
|
92
|
+
params = URI.decode_www_form(query).to_h
|
|
93
|
+
RECAP_PARAMS.each { |key| (val = params[key]) && target[key.to_sym] = val }
|
|
94
|
+
target
|
|
95
|
+
rescue URI::InvalidURIError
|
|
96
|
+
target
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def extract_meeting_thread(url)
|
|
100
|
+
extract_thread_from_path(url) || extract_thread_from_query(url)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def extract_thread_from_path(url)
|
|
104
|
+
[JOIN_URL_PATTERN, CHAT_URL_PATTERN].each do |pattern|
|
|
105
|
+
match = url.match(pattern)
|
|
106
|
+
next unless match
|
|
107
|
+
|
|
108
|
+
decoded = URI.decode_www_form_component(match[1])
|
|
109
|
+
return decoded if decoded.start_with?(MEETING_THREAD_PREFIX)
|
|
110
|
+
end
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def extract_thread_from_query(url)
|
|
115
|
+
query = URI.parse(url).query
|
|
116
|
+
return unless query
|
|
117
|
+
|
|
118
|
+
thread_param = URI.decode_www_form(query).to_h['threadId']
|
|
119
|
+
return unless thread_param
|
|
120
|
+
|
|
121
|
+
decoded = URI.decode_www_form_component(thread_param)
|
|
122
|
+
decoded if decoded.start_with?(MEETING_THREAD_PREFIX)
|
|
123
|
+
rescue URI::InvalidURIError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def resolve_event_target(event_id)
|
|
128
|
+
debug("Resolving event ID: #{event_id}")
|
|
129
|
+
join_url = fetch_event_join_url(event_id)
|
|
130
|
+
return nil unless join_url
|
|
131
|
+
|
|
132
|
+
thread_id = extract_meeting_thread(join_url)
|
|
133
|
+
return error('Could not extract thread ID from meeting link') && nil unless thread_id
|
|
134
|
+
|
|
135
|
+
{ thread_id: thread_id, event_id: event_id }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def fetch_event_join_url(event_id)
|
|
139
|
+
event = with_token_refresh { runner.calendar_api.get_event(event_id: event_id, timezone: 'UTC') }
|
|
140
|
+
event.online_meeting_url || (error('Event has no Teams meeting link') && nil)
|
|
141
|
+
rescue ApiError => e
|
|
142
|
+
error("Failed to fetch event: #{e.message}")
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def missing_target_error
|
|
147
|
+
error('Target required. Specify a thread ID, event ID, or Teams meeting URL.')
|
|
148
|
+
puts
|
|
149
|
+
puts 'Usage: teems meeting <target> [options]'
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Parses meeting chat messages to extract call events, recordings, transcripts
|
|
155
|
+
module MeetingMessageParser
|
|
156
|
+
PART_RE = %r{<part\s[^>]*identity="([^"]+)"[^>]*>.*?<name>([^<]*)</name>
|
|
157
|
+
.*?<displayName>([^<]*)</displayName>.*?<duration>([^<]*)</duration>}xm
|
|
158
|
+
CALLID_RE = %r{<callId>([^<]+)</callId>}
|
|
159
|
+
INSTANCE_ICAL_RE = %r{<instanceDetails>.*?<iCalUid>([^<]+)</iCalUid>}m
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def classify_meeting_messages(messages_data)
|
|
164
|
+
result = empty_classification
|
|
165
|
+
messages_data.each { |msg| classify_single_message(msg, result) }
|
|
166
|
+
result[:call_events].reject! { |evt| evt[:participants].empty? }
|
|
167
|
+
result
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def empty_classification
|
|
171
|
+
{ call_events: [], recordings: [], transcripts: [], chat_messages: [] }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def classify_single_message(msg, result)
|
|
175
|
+
case msg['messagetype']
|
|
176
|
+
when 'Event/Call' then result[:call_events] << parse_call_event(msg)
|
|
177
|
+
when 'RichText/Media_CallRecording' then result[:recordings] << parse_recording(msg)
|
|
178
|
+
when 'RichText/Media_CallTranscript' then result[:transcripts] << parse_transcript(msg)
|
|
179
|
+
else result[:chat_messages] << msg unless system_activity?(msg)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def system_activity?(msg)
|
|
184
|
+
type = msg['messagetype'].to_s
|
|
185
|
+
type.start_with?('ThreadActivity/') || type == 'Control/Typing'
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def parse_call_event(msg)
|
|
189
|
+
content = msg['content'].to_s
|
|
190
|
+
build_msg_hash(msg).merge(
|
|
191
|
+
call_id: content.match(CALLID_RE)&.captures&.first,
|
|
192
|
+
ical_uid: content.match(INSTANCE_ICAL_RE)&.captures&.first,
|
|
193
|
+
participants: extract_partlist(content)
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def extract_partlist(content)
|
|
198
|
+
content.scan(PART_RE).filter_map do |identity, _name, display, duration|
|
|
199
|
+
build_participant(identity, display, duration) unless identity.start_with?('28:')
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def build_participant(identity, display_name, duration)
|
|
204
|
+
name = decode_xml_entities(display_name)
|
|
205
|
+
{ identity: identity, name: name, duration: duration }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def decode_xml_entities(text)
|
|
209
|
+
text.gsub('&', '&').gsub(''', "'").gsub('<', '<').gsub('>', '>').gsub('"', '"')
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def parse_recording(msg)
|
|
213
|
+
content = msg['content'].to_s
|
|
214
|
+
build_msg_hash(msg).merge(url: extract_href(content), call_id: extract_call_id(content))
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def parse_transcript(msg)
|
|
218
|
+
props_raw = msg.dig('properties', 'cards') || msg.dig('properties', 'callTranscript')
|
|
219
|
+
build_msg_hash(msg).merge(properties: safe_parse_json(props_raw))
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def build_msg_hash(msg)
|
|
223
|
+
{ id: msg['id'], time: msg['composetime'], content: msg['content'].to_s }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def extract_href(html)
|
|
227
|
+
match = html.match(/href="([^"]+)"/)
|
|
228
|
+
match ? match[1] : nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def extract_call_id(content)
|
|
232
|
+
match = content.match(/callId=([a-f0-9-]+)/i)
|
|
233
|
+
match ? match[1] : nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def safe_parse_json(raw)
|
|
237
|
+
return nil unless raw.is_a?(String) && !raw.empty?
|
|
238
|
+
|
|
239
|
+
JSON.parse(raw)
|
|
240
|
+
rescue JSON::ParserError
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Displays meeting summary information
|
|
246
|
+
module MeetingDisplay
|
|
247
|
+
private
|
|
248
|
+
|
|
249
|
+
def display_meeting_summary(target, classified)
|
|
250
|
+
puts output.bold('Meeting Details')
|
|
251
|
+
puts " Thread: #{target[:thread_id]}"
|
|
252
|
+
display_organizer(target[:organizerId])
|
|
253
|
+
display_call_events(classified[:call_events])
|
|
254
|
+
display_assets_summary(classified)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def display_organizer(organizer_id)
|
|
258
|
+
return unless organizer_id
|
|
259
|
+
|
|
260
|
+
profile = with_token_refresh { runner.users_api.get_user(organizer_id) }
|
|
261
|
+
puts " Organizer: #{profile.display_name}"
|
|
262
|
+
rescue ApiError => e
|
|
263
|
+
debug("Could not resolve organizer: #{e.message}")
|
|
264
|
+
nil
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def display_call_events(call_events)
|
|
268
|
+
return puts(' No call events found') if call_events.empty?
|
|
269
|
+
|
|
270
|
+
call_events.each { |event| display_single_call_event(event) }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def display_single_call_event(event)
|
|
274
|
+
puts
|
|
275
|
+
puts " #{output.bold('Call Event')} #{format_time_str(event[:time])}"
|
|
276
|
+
display_participants(event[:participants])
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def display_participants(parts)
|
|
280
|
+
return if parts.empty?
|
|
281
|
+
|
|
282
|
+
puts " Participants (#{parts.length}):"
|
|
283
|
+
parts.each { |entry| puts format_participant_line(entry[:name], entry[:identity], entry[:duration]) }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def format_participant_line(name, identity, duration)
|
|
287
|
+
resolved = needs_resolution?(name) ? resolve_participant_name(identity) : name
|
|
288
|
+
" #{resolved} (#{format_call_duration(duration)})"
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def needs_resolution?(name)
|
|
292
|
+
name.empty? || name.match?(/\A\d*:/)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def resolve_participant_name(identity)
|
|
296
|
+
uuid = identity.match(/8:orgid:(.+)/)&.captures&.first
|
|
297
|
+
return identity unless uuid
|
|
298
|
+
|
|
299
|
+
@name_cache ||= {}
|
|
300
|
+
@name_cache[uuid] ||= fetch_user_name(uuid, identity)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def fetch_user_name(uuid, identity)
|
|
304
|
+
profile = with_token_refresh { runner.users_api.get_user(uuid) }
|
|
305
|
+
profile.display_name
|
|
306
|
+
rescue ApiError => e
|
|
307
|
+
debug("Could not resolve user #{uuid}: #{e.message}")
|
|
308
|
+
identity
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def format_call_duration(seconds_str)
|
|
312
|
+
total = seconds_str.to_i
|
|
313
|
+
return '< 1 min' if total < 60
|
|
314
|
+
|
|
315
|
+
mins = total / 60
|
|
316
|
+
"#{mins} min"
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def display_assets_summary(classified)
|
|
320
|
+
lines = asset_lines(classified[:recordings], 'Recordings') +
|
|
321
|
+
asset_lines(classified[:transcripts], 'Transcripts')
|
|
322
|
+
return if lines.empty?
|
|
323
|
+
|
|
324
|
+
puts
|
|
325
|
+
puts " #{output.bold('Assets')}:"
|
|
326
|
+
lines.each { |line| puts line }
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def asset_lines(items, label)
|
|
330
|
+
items.empty? ? [] : [" #{label}: #{items.length}"]
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def format_time_str(time_str)
|
|
334
|
+
return '' unless time_str
|
|
335
|
+
|
|
336
|
+
parsed = Time.parse(time_str)
|
|
337
|
+
output.blue("[#{parsed.strftime('%Y-%m-%d %H:%M')}]")
|
|
338
|
+
rescue ArgumentError
|
|
339
|
+
''
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Displays meeting chat messages (non-system messages)
|
|
344
|
+
module MeetingChatDisplay
|
|
345
|
+
private
|
|
346
|
+
|
|
347
|
+
def display_meeting_chat(chat_messages)
|
|
348
|
+
messages = chat_messages.map { |msg_data| Models::Message.from_api(msg_data) }
|
|
349
|
+
.reject(&:system_message?)
|
|
350
|
+
.reverse
|
|
351
|
+
return puts('No chat messages found') if messages.empty?
|
|
352
|
+
|
|
353
|
+
formatter = runner.message_formatter
|
|
354
|
+
messages.each { |msg| puts formatter.format(msg) }
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Filters classified messages to a specific call instance by callId
|
|
359
|
+
module MeetingCallFilter
|
|
360
|
+
private
|
|
361
|
+
|
|
362
|
+
def classify_and_filter(messages_data, target)
|
|
363
|
+
classified = classify_meeting_messages(messages_data)
|
|
364
|
+
filter_classified(classified, target)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def filter_classified(classified, target)
|
|
368
|
+
ical = target[:iCalUid]
|
|
369
|
+
return filter_by_ical(classified, ical) if ical
|
|
370
|
+
|
|
371
|
+
call_id = target[:callId]
|
|
372
|
+
call_id ? filter_by_call_id(classified, call_id) : classified
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def filter_by_ical(classified, ical_uid)
|
|
376
|
+
events = classified[:call_events].select { |evt| evt[:ical_uid] == ical_uid }
|
|
377
|
+
return classified if events.empty?
|
|
378
|
+
|
|
379
|
+
classified.merge(call_events: events)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def filter_by_call_id(classified, call_id)
|
|
383
|
+
events = classified[:call_events].select { |evt| evt[:call_id] == call_id }
|
|
384
|
+
return classified if events.empty?
|
|
385
|
+
|
|
386
|
+
classified.merge(call_events: events)
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# View meeting details, chat, transcripts, and recordings
|
|
391
|
+
class Meeting < Base
|
|
392
|
+
include MeetingTargetResolver
|
|
393
|
+
include MeetingMessageParser
|
|
394
|
+
include MeetingDisplay
|
|
395
|
+
include MeetingChatDisplay
|
|
396
|
+
include MeetingCallFilter
|
|
397
|
+
include MeetingTranscript
|
|
398
|
+
include MeetingRecording
|
|
399
|
+
|
|
400
|
+
def initialize(args, runner:)
|
|
401
|
+
@options = {}
|
|
402
|
+
super
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def execute
|
|
406
|
+
result = validate_options
|
|
407
|
+
return result if result
|
|
408
|
+
|
|
409
|
+
auth_result = require_auth
|
|
410
|
+
return auth_result if auth_result
|
|
411
|
+
|
|
412
|
+
target = resolve_meeting_target
|
|
413
|
+
target ? process_meeting(target) : 1
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
protected
|
|
417
|
+
|
|
418
|
+
MEETING_OPTIONS = MeetingOptionDefs::ALL
|
|
419
|
+
|
|
420
|
+
def handle_option(arg, pending)
|
|
421
|
+
handler = MEETING_OPTIONS[arg]
|
|
422
|
+
return super unless handler
|
|
423
|
+
|
|
424
|
+
handler.call(@options, pending)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def help_text = MEETING_HELP
|
|
428
|
+
|
|
429
|
+
private
|
|
430
|
+
|
|
431
|
+
def process_meeting(target)
|
|
432
|
+
messages_data = fetch_meeting_messages(target[:thread_id])
|
|
433
|
+
return 1 unless messages_data
|
|
434
|
+
|
|
435
|
+
classified = classify_and_filter(messages_data, target)
|
|
436
|
+
dispatch_mode(target, classified)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def fetch_meeting_messages(thread_id)
|
|
440
|
+
debug("Fetching messages for thread: #{thread_id}")
|
|
441
|
+
response = with_token_refresh do
|
|
442
|
+
runner.messages_api.chat_messages(chat_id: thread_id, limit: @options[:limit])
|
|
443
|
+
end
|
|
444
|
+
response['messages'] || response['posts'] || response['value'] || []
|
|
445
|
+
rescue ApiError => e
|
|
446
|
+
error("Failed to fetch meeting messages: #{e.message}")
|
|
447
|
+
nil
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def dispatch_mode(target, classified)
|
|
451
|
+
if @options[:chat]
|
|
452
|
+
display_meeting_chat(classified[:chat_messages])
|
|
453
|
+
return 0
|
|
454
|
+
end
|
|
455
|
+
return download_recording_with_transcript(target, classified) if @options[:recording]
|
|
456
|
+
return download_transcript(target, classified) || 0 if @options[:transcript]
|
|
457
|
+
|
|
458
|
+
display_meeting_summary(target, classified)
|
|
459
|
+
0
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def download_recording_with_transcript(target, classified)
|
|
463
|
+
if @options[:transcript]
|
|
464
|
+
result = download_transcript(target, classified)
|
|
465
|
+
warn('Transcript download failed; proceeding with recording') if result == 1
|
|
466
|
+
end
|
|
467
|
+
download_recording(target, classified) || 0
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
end
|