dubbletrack_remote 0.7.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.
@@ -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