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.
- 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
|