dubbletrack_remote 0.7.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,353 @@
1
+ require "thor/rake_compat"
2
+ require "rspec/core/rake_task"
3
+ require "sinatra/activerecord/rake"
4
+ require "pry"
5
+ require "fileutils"
6
+
7
+ module DubbletrackRemote
8
+ class CLI < Thor
9
+ include Thor::Actions
10
+ include Thor::RakeCompat
11
+
12
+ class_option :debug, type: :boolean, default: false
13
+ class_option :dir, type: :string, default: "/opt/dubbletrack-remote/"
14
+
15
+ desc "watch", "watch playlists folder and post on changes"
16
+ def watch
17
+ ensure_setup
18
+
19
+ Reader.watch(watch_path, pattern: watch_pattern) do |file|
20
+ puts "detected change in #{file}"
21
+ Reader.new(file, {cuts_path: local_cuts_file}).ingest
22
+ end
23
+
24
+ Thread.new do
25
+ loop do
26
+ Item.next(20).each do |item|
27
+ client.send([item])
28
+ end
29
+ update # this feels redundant with the watch above
30
+ status
31
+ rescue => e
32
+ puts "errored, but will try to move on"
33
+ puts e
34
+ ensure
35
+ sleep 10
36
+ end
37
+ end
38
+
39
+ backfill
40
+
41
+ sleep
42
+ end
43
+ default_task :watch
44
+
45
+ desc "status", "get status"
46
+ def status
47
+ ensure_setup
48
+ puts "total remaining: #{Item.remaining.count}"
49
+ puts "total transmitted: #{Item.successful.count}"
50
+ puts "recent items needing transmission: #{Item.recent_remaining.count}"
51
+ end
52
+
53
+ desc "config", "open config file"
54
+ def config
55
+ system("open #{settings_path}")
56
+ end
57
+
58
+ desc "setup", "setup dubbletrack remote for the first time"
59
+ def setup
60
+ create_settings_file
61
+ ensure_setup
62
+ end
63
+
64
+ desc "update", "post latest tracks from current playlist"
65
+ def update
66
+ ensure_setup
67
+
68
+ if todays_playlist_path
69
+ [todays_playlist_path].flatten.each { |f| Reader.new(f, {cuts_path: local_cuts_file}).ingest }
70
+ else
71
+ puts "no file found matching pattern #{playlist_pattern}"
72
+ end
73
+ end
74
+
75
+ desc "post FILE_PATH or DATES", "post latest track from playlist"
76
+ def post(*args)
77
+ ensure_setup
78
+ paths = args_to_paths(args)
79
+
80
+ if paths.any?
81
+ items = paths.collect do |path|
82
+ puts "reading #{path}"
83
+ items = Reader.new(path, {cuts_path: local_cuts_file}).items
84
+ end.flatten.sort_by(&:played_at)
85
+ remaining = Item.where(id: items.pluck(:id)).remaining
86
+
87
+ if remaining.count > 0
88
+ puts "sending #{remaining.size} from playlist #{file_path}"
89
+ Item.where(id: remaining.pluck(:id)).remaining.find_each do |item|
90
+ client.send(item)
91
+ end
92
+ end
93
+ else
94
+ puts "could not find any files with args: #{args}"
95
+ end
96
+ end
97
+
98
+ # desc "next SIZE", "check the next up to be sent"
99
+ # def next(size = 20)
100
+ # ensure_setup
101
+
102
+ # Item.next(size).each do |item|
103
+ # puts item.pretty_print
104
+ # end
105
+ # end
106
+
107
+ desc "read FILE_PATH or DATES", "read tracks from playlist"
108
+ option :raw, type: :boolean, description: "Show raw fields?", default: false
109
+ def read(*args)
110
+ ensure_setup
111
+ paths = args_to_paths(args)
112
+ if paths.any?
113
+ items = paths.collect do |path|
114
+ puts "reading #{path}"
115
+ items = Reader.new(path, {cuts_path: local_cuts_file}).items
116
+ end.flatten.sort_by(&:played_at)
117
+
118
+ items.each do |e|
119
+ puts e.pretty_print
120
+ puts e.raw if options[:raw]
121
+ end
122
+ else
123
+ puts "could not find any files with args: #{args}"
124
+ end
125
+ end
126
+
127
+ desc "import FILE_PATH or DATES", "import tracks from playlist"
128
+ def import(*args)
129
+ ensure_setup
130
+ paths = args_to_paths(args)
131
+ if paths.any?
132
+ paths.each do |path|
133
+ puts "importing #{path}"
134
+ items = Reader.new(path, {cuts_path: local_cuts_file}).ingest
135
+ items.each { |e| puts e.pretty_print }
136
+ end
137
+ else
138
+ puts "could not find any files with args:"
139
+ end
140
+ end
141
+
142
+ desc "backfill", "read previous playlists and post changes"
143
+ def backfill(from_date = nil)
144
+ ensure_setup
145
+
146
+ from_date = from_date ? Date.parse(from_date) : Date.today - 7.days
147
+
148
+ paths = []
149
+ while from_date < Date.today
150
+ paths << playlist_path_from_date(from_date)
151
+ from_date += 1.day
152
+ end
153
+
154
+ paths.flatten!
155
+
156
+ if paths.any?
157
+ paths.each do |path|
158
+ puts "importing #{path}"
159
+ items = Reader.new(path, {cuts_path: local_cuts_file}).ingest
160
+ items.each { |e| puts e.pretty_print }
161
+ end
162
+ else
163
+ puts "could not find any files with args:"
164
+ end
165
+ end
166
+
167
+ # desc "list", "list all available files based on settings"
168
+ # def list
169
+ # ensure_setup
170
+ # playlist_pattern, _ = settings[:automation][:playlist_path].split("%")
171
+ # playlist_pattern = "#{playlist_pattern}*"
172
+ # files = Dir.glob(playlist_pattern)
173
+ # files.map { |f| File.expand_path(f) }
174
+ # end
175
+
176
+ desc "console", ""
177
+ def console
178
+ ensure_setup
179
+
180
+ # turns on logging of SQL queries while in the task
181
+ ActiveRecord::Base.logger = Logger.new(STDOUT)
182
+ # starts a Ruby REPL session
183
+ Pry.start
184
+ end
185
+
186
+ desc "version", "prints current dubbletrack_remote version"
187
+ def version
188
+ ensure_setup
189
+
190
+ puts "version: #{DubbletrackRemote::VERSION}"
191
+ end
192
+
193
+ # no_commands do
194
+ # def options
195
+ # super
196
+ # # original_options = super
197
+ # # defaults = if original_options[:dir] # the settings path was passed in
198
+ # # config = File.expand_path(File.join(original_options[:dir], settings_file_name))
199
+ # # if File.exist?(config)
200
+ # # ::YAML.load_file(config) || {}
201
+ # # else
202
+ # # {}
203
+ # # end
204
+ # # end
205
+ # # return Thor::CoreExt::HashWithIndifferentAccess.new(defaults.merge(original_options))
206
+ # end
207
+ # end
208
+
209
+ protected
210
+
211
+ def update_current_cuts_file?
212
+ return true if find_local_cuts_file.blank?
213
+
214
+ latest_time = Time.at(File.basename(find_local_cuts_file, File.extname(find_local_cuts_file)).split("_").last.to_i)
215
+ latest_time < 6.hours.ago
216
+ end
217
+
218
+ def delete_old_cuts
219
+ deletable = Dir.glob(File.join(options[:dir], "CUTS_*.DBF")).sort.reverse.slice(1...-1) || []
220
+ deletable.each { |to_delete| FileUtils.rm(to_delete) }
221
+ end
222
+
223
+ def find_local_cuts_file
224
+ cuts = Dir.glob(File.join(options[:dir], "CUTS_*.DBF")).sort.reverse
225
+ cuts.last
226
+ end
227
+
228
+ def local_cuts_file
229
+ if remote_cuts_path = Settings.automation.cuts_path
230
+ if update_current_cuts_file?
231
+ current_cut_path = File.join(options[:dir], "CUTS_#{Time.now.to_i}.DBF")
232
+ delete_old_cuts
233
+ FileUtils.cp(remote_cuts_path, current_cut_path)
234
+
235
+ return current_cut_path
236
+ end
237
+
238
+ find_local_cuts_file
239
+ end
240
+ end
241
+
242
+ def args_to_paths(args)
243
+ paths = []
244
+ args.each do |arg|
245
+ if File.exist?(arg)
246
+ paths << arg
247
+ elsif arg.scan(/\d\d\d\d-\d\d-\d\d/)
248
+ paths << playlist_path_from_date(Time.zone.parse(arg))
249
+ end
250
+ end
251
+
252
+ paths = paths.flatten.compact
253
+ end
254
+
255
+ def client
256
+ @client ||= Client.new(
257
+ url: settings[:api_url],
258
+ key: settings[:api_key],
259
+ secret: settings[:api_secret]
260
+ )
261
+ end
262
+
263
+ def watch_path
264
+ path = File.expand_path(settings.dig(:automation, :playlist_path))
265
+ File.dirname(path)
266
+ end
267
+
268
+ def watch_pattern
269
+ path = File.expand_path(settings.dig(:automation, :playlist_path))
270
+ Regexp.new(File.basename(path).gsub(/%\w/, ".+"), "i")
271
+ end
272
+
273
+ def watch_extension
274
+ path = File.expand_path(settings.dig(:automation, :playlist_path))
275
+ Regexp.new("#{File.extname(path).downcase}$", "i")
276
+ end
277
+
278
+ def todays_playlist_path
279
+ playlist_path_from_date(Time.now)
280
+ end
281
+
282
+ def playlist_path_from_date(date = Time.now)
283
+ playlist_pattern = date.strftime(settings[:automation][:playlist_path])
284
+ files = Dir.glob(playlist_pattern)
285
+ files.map { |f| File.expand_path(f) }
286
+ end
287
+
288
+ def settings
289
+ Settings
290
+ end
291
+
292
+ def settings_path
293
+ File.expand_path(File.join(options[:dir], settings_file_name))
294
+ end
295
+
296
+ def settings_file_name
297
+ ".dubbletrack-remote-settings"
298
+ end
299
+
300
+ def ensure_setup
301
+ connect_db
302
+ if !File.exist?(settings_path)
303
+ if yes? "dubbletrack_remote is not setup at #{options[:dir]}. Setup? (y/n)"
304
+ create_settings_file
305
+ end
306
+ end
307
+ Config.load_and_set_settings(settings_path)
308
+ Time.zone = Settings.automation.time_zone
309
+ begin
310
+ Rake::Task["db:migrate"].invoke
311
+ rescue
312
+ end
313
+ end
314
+
315
+ def create_settings_file
316
+ content = ""
317
+ create_file(settings_path) do
318
+ station_id = ask "What's your dubbletrack station ID? (e.g. koop, kvrx)"
319
+ api_key = ask "What's your dubbletrack API Key?"
320
+ api_secret = ask "What's your dubbletrack API Secret?", echo: false
321
+ automation_type = ask "What kind of automation system is this?", default: "enco"
322
+ dump_delay = ask "How much of a delay (in seconds) is there before broadcast? (i.e. do you have a dump button)", default: 0
323
+ time_zone = ask "What timezone are you in?", default: "America/Chicago"
324
+
325
+ if automation_type == "enco"
326
+ format = "dbf"
327
+ cuts_path = ask "Where is the cuts database? (CUTS.DBF)", path: true
328
+ playlists_path = ask "Where should I look for playlists? (This can include regex and strftime components, i.e. /Enco/Dad/STUDIO[1-2]%m%d%y.DBF)", path: true
329
+ end
330
+ <<~YAMS.strip
331
+ api_url: https://api.dubbletrack.com/api/v1/stations/#{station_id}/automation
332
+ api_key: #{api_key}
333
+ api_secret: #{api_secret}
334
+ automation:
335
+ time_zone: #{time_zone}
336
+ type: #{automation_type}
337
+ playlist_path: #{playlists_path}
338
+ cuts_path: #{cuts_path}
339
+ delay_seconds: #{dump_delay}
340
+ YAMS
341
+ end
342
+ end
343
+
344
+ def connect_db
345
+ unless ENV["DUBBLETRACK_REMOTE_ENV"] == "test"
346
+ Sinatra::Application.set :database, {
347
+ adapter: "sqlite3",
348
+ database: File.expand_path(File.join(options[:dir], "dubbletrack-remote.sqlite3"))
349
+ }
350
+ end
351
+ end
352
+ end
353
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DubbletrackRemote
4
+ class Client
5
+ def initialize(url:, key:, secret:)
6
+ @client = Faraday.new(url: url) do |faraday|
7
+ faraday.request :retry,
8
+ max: 2,
9
+ interval: 0.05,
10
+ interval_randomness: 0.5,
11
+ backoff_factor: 2
12
+
13
+ faraday.adapter Faraday.default_adapter # make requests with Net::HTTP
14
+ end
15
+
16
+ @url = url # || 'https://dubbletrack.com/api/v1/stations/koop/shows/koop-automation/tracks'
17
+ @key = key # || 'dt_xxxxxxxx'
18
+ @secret = secret # || 'dts_xxxxxxxxxxxx'
19
+ end
20
+
21
+ def send(items)
22
+ items = [items].flatten
23
+
24
+ payload = JSON.generate(
25
+ data: items.collect { |item| item_json(item) }
26
+ )
27
+
28
+ headers = {
29
+ :"Content-Type" => "application/json",
30
+ :"X-API-KEY" => @key,
31
+ :"X-API-SECRET" => @secret,
32
+ "User-Agent" => "Dubbletrack Remote"
33
+ }
34
+
35
+ items.each do |i|
36
+ i.last_attempt_at = Time.now.iso8601
37
+ end
38
+
39
+ begin
40
+ response = @client.post(@url, payload, headers)
41
+ if response.success?
42
+ data = JSON.parse(response.body).dig("data")
43
+
44
+ begin
45
+ items.each do |item|
46
+ item.success = true
47
+ item.last_error_text = nil
48
+ item.last_error_code = nil
49
+
50
+ found_response = if data.size > 1
51
+ data.find do |d|
52
+ d["attributes"]["title"] == item.title &&
53
+ d["attributes"]["artist_name"] == item.artist_name
54
+ end
55
+ else
56
+ data.first
57
+ end
58
+ item.remote_id = found_response["id"] if found_response
59
+ item.save
60
+ puts item.pretty_print
61
+ end
62
+ rescue => e
63
+ puts e
64
+ end
65
+ else
66
+ items.each do |item|
67
+ item.success = false
68
+ error = DubbletrackRemote::Error.new(response)
69
+ raise DubbletrackRemote::RateLimitError if error.text == "Rate Limit Exceeded"
70
+ item.last_error_text = error.text
71
+ item.last_error_code = error.code
72
+ item.save
73
+ puts item.pretty_print
74
+ end
75
+ end
76
+ rescue Faraday::ConnectionFailed => exception
77
+ items.each do |item|
78
+ item.last_error_text = "connection failed"
79
+ item.last_error_code = 404
80
+ item.save
81
+ end
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def key_map(key)
88
+ map = {
89
+ automation_group: :remote_automation_group,
90
+ automation_id: :remote_automation_id,
91
+ automation_system: :remote_automation_system,
92
+ source: :remote_automation_source,
93
+ guid: :remote_automation_guid
94
+ }
95
+
96
+ map[key] || key
97
+ end
98
+
99
+ def item_json(item)
100
+ attributes = [
101
+ :title,
102
+ :artist_name,
103
+ :release_name,
104
+ :label_name,
105
+ :played_at,
106
+ :duration,
107
+ :genre,
108
+ :automation_group,
109
+ :automation_id,
110
+ :automation_system,
111
+ :source,
112
+ :guid
113
+ ].each_with_object({}) do |key, hash|
114
+ value = begin
115
+ item.send(key)
116
+ rescue
117
+ nil
118
+ end
119
+ if value && key_map(key).is_a?(Symbol)
120
+ hash[key_map(key)] = value
121
+ elsif key_map(key).is_a?(String)
122
+ hash[key] = key_map(key)
123
+ end
124
+
125
+ if key == :played_at && item.respond_to?(:played_at_with_delay)
126
+ hash[:played_at] = item.send(:played_at_with_delay)
127
+ end
128
+ end
129
+
130
+ {
131
+ type: item.item_type,
132
+ attributes: attributes
133
+ }
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DubbletrackRemote
4
+ class RateLimitError
5
+ end
6
+
7
+ class Error
8
+ attr_accessor :time, :text, :code
9
+
10
+ def initialize(response)
11
+ @time = Time.now.iso8601
12
+ @code = response.status
13
+ @text = error_text(response)
14
+ end
15
+
16
+ def error_text(response)
17
+ error_text = response.body
18
+ begin
19
+ json = JSON.parse(response.body)
20
+ errors = json["errors"] || []
21
+ error_text = errors.flatten.collect { |error| error["detail"] }.join(",")
22
+ rescue JSON::ParserError
23
+ # keep the error text as response.body
24
+ end
25
+ error_text
26
+ end
27
+
28
+ def to_json
29
+ {
30
+ text: @text,
31
+ code: @code,
32
+ time: @time
33
+ }
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,79 @@
1
+ # An item to be sent to dubbletrack. Either a track or a traffic update, can be created from a hash or a db record
2
+ # module DubbletrackRemote
3
+ module DubbletrackRemote
4
+ class Item < ActiveRecord::Base
5
+ TRACK = "track"
6
+ TRAFFIC = "traffic"
7
+
8
+ # TODO: fix these scopes so they're not so horrendous, is there a way to make sqlite play nicer with booleans?
9
+
10
+ scope :successful, -> { where(["success = ? OR success = ?", 1, true]) }
11
+ scope :known, -> { where("item_type IN (?)", [TRACK, TRAFFIC]) }
12
+ scope :remaining, -> { known.where(["(success IS NULL OR success = ?) AND (last_error_text IS NULL OR last_error_text != ?)", false, "Played at has already been taken"]) }
13
+ scope :tracks, -> { where(["item_type = ?", TRACK]) }
14
+ scope :traffic, -> { where(["item_type = ?", TRAFFIC]) }
15
+ scope :recent_remaining, -> { remaining.where(["played_at > ?", 1.day.ago]) }
16
+ scope :next, ->(count = 10) { remaining.order("played_at desc").limit(count) }
17
+ scope :on_date, ->(time) { where(["played_at >= ? AND played_at <= ?", time.beginning_of_day, time.end_of_day]) }
18
+
19
+ validates :played_at, uniqueness: true
20
+ # validates :intended_played_at, uniqueness: true, allow_blank: true
21
+
22
+ before_validation :set_item_type
23
+ before_save :set_item_type
24
+
25
+ attr_accessor :raw
26
+
27
+ def played_at_with_delay
28
+ played_at + (Settings&.automation&.delay_seconds || 0).seconds
29
+ rescue
30
+ played_at
31
+ end
32
+
33
+ def set_item_type
34
+ self.item_type = detect_item_type
35
+ end
36
+
37
+ def detect_item_type
38
+ return TRACK if track?
39
+ return TRAFFIC if traffic?
40
+ end
41
+
42
+ def key
43
+ "#{played_at.utc.iso8601}-#{title}:#{artist_name}"
44
+ end
45
+
46
+ def track?
47
+ title.present? && played_at.present? && artist_name.present? && (label_name.present? || release_name.present?) # label name and release name could technically be left out, even though they shouldn't
48
+ end
49
+
50
+ def traffic?
51
+ title.present? && played_at.present? && !track?
52
+ end
53
+
54
+ def pretty_print
55
+ state = if success
56
+ "[✓] "
57
+ elsif last_error_code
58
+ "[×] "
59
+ else
60
+ "[ ] "
61
+ end
62
+ duration_s = duration ? " [#{duration}s]" : ""
63
+ if track?
64
+ line = "TRACK: #{state}#{id}:#{played_at.utc.iso8601}".ljust(30) + "#{title} - #{artist_name}" "\r\n" +
65
+ " ".ljust(32) + "#{release_name} // #{label_name} \r\n" +
66
+ " ".ljust(32) + "#{genre}, #{duration_s}, #{automation_group}, #{automation_id}" + "\r\n" +
67
+ (last_error_code ? "".ljust(30) + "[#{last_error_code}] #{last_error_text}" : "") + "\r\n"
68
+ elsif traffic?
69
+ line = "TRAFFIC: #{state}#{id}:#{played_at.utc.iso8601}".ljust(30) + title.to_s + "\r\n" +
70
+ "#{genre}, #{duration_s}, #{automation_group}, #{automation_id}" + "\r\n" +
71
+ (last_error_code ? "".ljust(30) + "[#{last_error_code}] #{last_error_text}" : "") + "\r\n"
72
+ else
73
+ line = "UNKNOWN: #{state}#{id}:#{played_at.utc.iso8601}".ljust(30) + title.to_s + "\r\n" +
74
+ "#{genre}, #{duration_s}, #{automation_group}, #{automation_id}" + "\r\n" +
75
+ (last_error_code ? "".ljust(30) + "[#{last_error_code}] #{last_error_text}" : "") + "\r\n"
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,57 @@
1
+ # Reads in a DBF file and creates items out of them, annotating with information from CUTS
2
+ module DubbletrackRemote
3
+ module Reader
4
+ def self.new(file_path, options = {})
5
+ if File.extname(file_path) == ".DBF"
6
+ DubbletrackRemote::Reader::DBF.new(file_path, options)
7
+ elsif File.extname(file_path) == ".TSV"
8
+ DubbletrackRemote::Reader::TSV.new(file_path, options)
9
+ end
10
+ end
11
+
12
+ def self.watch(playlist_path, options = {}, &block)
13
+ pattern = options[:pattern] || Regexp.new(".+")
14
+ listener = Listen.to(playlist_path, force_polling: true, wait_for_delay: 4) do |modified, added|
15
+ [modified, added].flatten.compact.uniq.each do |file|
16
+ if pattern.match(file)
17
+ block.call(file)
18
+ end
19
+ end
20
+ rescue => e
21
+ puts "errored while listening for changes, will continue listening"
22
+ puts e
23
+ end
24
+
25
+ listener.start
26
+ end
27
+
28
+ class Base
29
+ attr_reader :use_database
30
+
31
+ def items
32
+ @items ||= read_items.sort_by(&:played_at)
33
+ end
34
+
35
+ def tracks
36
+ items.select { |i| i.item_type == DubbletrackRemote::Item::TRACK }
37
+ end
38
+
39
+ def traffic
40
+ items.select { |i| i.item_type == DubbletrackRemote::Item::TRAFFIC }
41
+ end
42
+
43
+ def ingest
44
+ items.select { |s| s.changed? || s.new_record? }.each do |item|
45
+ puts item.to_json
46
+ item.save
47
+ end
48
+
49
+ items
50
+ end
51
+
52
+ def read_items
53
+ raise "must implement in subclass"
54
+ end
55
+ end
56
+ end
57
+ end