dubbletrack_remote 0.7.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/release.yml +27 -0
- data/.github/workflows/ruby.yml +46 -0
- data/.gitignore +48 -0
- data/.rspec +1 -0
- data/.rubocop.yml +54 -0
- data/.tool-versions +1 -0
- data/Gemfile +2 -0
- data/README.md +14 -0
- data/Rakefile +29 -0
- data/bin/console +14 -0
- data/bin/dubbletrack_remote +6 -0
- data/config/database.yml +15 -0
- data/config/settings-example.yml +8 -0
- data/db/migrate/001_schema.rb +19 -0
- data/db/migrate/002_add_item_columns.rb +8 -0
- data/db/migrate/003_add_debug_item_columns.rb +7 -0
- data/db/migrate/004_add_automation_system_column.rb +5 -0
- data/db/schema.rb +40 -0
- data/db/seed.rb +0 -0
- data/dubbletrack-remote +0 -0
- data/dubbletrack_remote.gemspec +64 -0
- data/enco-support/Asplay.rpg +34 -0
- data/enco-support/readme.txt +15 -0
- data/lib/dubbletrack_remote/cli.rb +353 -0
- data/lib/dubbletrack_remote/client.rb +136 -0
- data/lib/dubbletrack_remote/errors.rb +36 -0
- data/lib/dubbletrack_remote/item.rb +79 -0
- data/lib/dubbletrack_remote/reader/base.rb +57 -0
- data/lib/dubbletrack_remote/reader/dbf.rb +143 -0
- data/lib/dubbletrack_remote/reader/tsv.rb +112 -0
- data/lib/dubbletrack_remote/version.rb +3 -0
- data/lib/dubbletrack_remote.rb +29 -0
- data/package.json +92 -0
- metadata +428 -0
@@ -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
|