dtas 0.11.0 → 0.12.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +4 -0
  3. data/Documentation/GNUmakefile +1 -1
  4. data/Documentation/dtas-console.txt +1 -0
  5. data/Documentation/dtas-player_protocol.txt +19 -5
  6. data/Documentation/dtas-splitfx.txt +13 -0
  7. data/Documentation/dtas-tl.txt +16 -0
  8. data/GIT-VERSION-GEN +1 -1
  9. data/GNUmakefile +2 -2
  10. data/INSTALL +3 -3
  11. data/README +4 -0
  12. data/bin/dtas-archive +5 -1
  13. data/bin/dtas-console +13 -6
  14. data/bin/dtas-cueedit +1 -1
  15. data/bin/dtas-mlib +47 -0
  16. data/bin/dtas-readahead +211 -0
  17. data/bin/dtas-sinkedit +1 -1
  18. data/bin/dtas-sourceedit +1 -1
  19. data/bin/dtas-splitfx +15 -6
  20. data/bin/dtas-tl +81 -5
  21. data/dtas.gemspec +2 -2
  22. data/lib/dtas.rb +17 -0
  23. data/lib/dtas/buffer/read_write.rb +21 -19
  24. data/lib/dtas/buffer/splice.rb +1 -2
  25. data/lib/dtas/format.rb +2 -2
  26. data/lib/dtas/mlib.rb +500 -0
  27. data/lib/dtas/mlib/migrations/0001_initial.rb +42 -0
  28. data/lib/dtas/nonblock.rb +24 -0
  29. data/lib/dtas/parse_freq.rb +29 -0
  30. data/lib/dtas/parse_time.rb +5 -2
  31. data/lib/dtas/pipe.rb +2 -1
  32. data/lib/dtas/player.rb +21 -41
  33. data/lib/dtas/player/client_handler.rb +175 -92
  34. data/lib/dtas/process.rb +41 -17
  35. data/lib/dtas/sigevent/pipe.rb +6 -5
  36. data/lib/dtas/sink.rb +1 -1
  37. data/lib/dtas/source/splitfx.rb +14 -0
  38. data/lib/dtas/splitfx.rb +52 -36
  39. data/lib/dtas/track.rb +13 -0
  40. data/lib/dtas/tracklist.rb +148 -43
  41. data/lib/dtas/unix_accepted.rb +49 -32
  42. data/lib/dtas/unix_client.rb +1 -1
  43. data/lib/dtas/unix_server.rb +17 -9
  44. data/lib/dtas/watchable.rb +16 -5
  45. data/test/test_env.rb +16 -0
  46. data/test/test_mlib.rb +31 -0
  47. data/test/test_parse_freq.rb +18 -0
  48. data/test/test_player_client_handler.rb +12 -12
  49. data/test/test_splitfx.rb +0 -29
  50. data/test/test_tracklist.rb +75 -17
  51. data/test/test_unixserver.rb +0 -11
  52. metadata +16 -4
@@ -78,7 +78,7 @@
78
78
  rset = [ sev ]
79
79
  if watch
80
80
  ino = DTAS::Watchable::InotifyReadableIter.new
81
- ino.watch_file(tmp_path, do_update)
81
+ ino.watch_files(tmp_path, do_update)
82
82
  rset << ino
83
83
  end
84
84
 
@@ -65,7 +65,7 @@
65
65
  rset = [ sev ]
66
66
  if watch
67
67
  ino = DTAS::Watchable::InotifyReadableIter.new
68
- ino.watch_file(tmp_path, do_update)
68
+ ino.watch_files(tmp_path, do_update)
69
69
  rset << ino
70
70
  end
71
71
 
@@ -6,18 +6,26 @@
6
6
  require 'dtas/splitfx'
7
7
  usage = "#$0 [-n|--dry-run][-j [JOBS]][-s|--silent] SPLITFX_FILE.yml [TARGET]"
8
8
  overrides = {} # FIXME: not tested
9
+ default_target = "flac"
9
10
  opts = { jobs: 1 }
10
- jobs = 1
11
11
  OptionParser.new('', 24, ' ') do |op|
12
12
  op.banner = usage
13
13
  op.on('-n', '--dry-run') { opts[:dryrun] = true }
14
- op.on('-j', '--jobs [JOBS]', Integer) { |val| opts[:jobs] = val }
15
- op.on('-s', '--quiet', '--silent') { |val| opts[:silent] = true }
16
- op.on('-D', '--no-dither') { |val| opts[:no_dither] = true }
14
+ op.on('-j', '--jobs [JOBS]', Integer) { |val| opts[:jobs] = val } # nil==inf
15
+ op.on('-s', '--quiet', '--silent') { opts[:silent] = true }
16
+ op.on('-D', '--no-dither') { opts[:no_dither] = true }
17
17
  op.on('-O', '--outdir OUTDIR') { |val| opts[:outdir] = val }
18
18
  op.on('-C', '--compression FACTOR') { |val| opts[:compression] = val }
19
- op.on('-r', '--rate RATE') { |val| opts[:rate] = val }
19
+ op.on('-r', '--rate RATE') do |val|
20
+ mult = val.sub!(/k\z/, '') ? 1000 : 1
21
+ opts[:rate] = (val.to_f * mult).to_i
22
+ end
20
23
  op.on('-b', '--bits RATE', Integer) { |val| opts[:bits] = val }
24
+ op.on('-t', '--trim POSITION') { |val| opts[:trim] = val.tr(',', ' ') }
25
+ op.on('-p', '--sox-pipe') do
26
+ opts[:sox_pipe] = true
27
+ default_target = 'sox'
28
+ end
21
29
  op.parse!(ARGV)
22
30
  end
23
31
 
@@ -38,8 +46,9 @@
38
46
  end
39
47
  end
40
48
 
49
+ trap(:INT) { exit 130 }
41
50
  file = args.shift or abort usage
42
- target = args.shift || "flac"
51
+ target = args.shift || default_target
43
52
  splitfx = DTAS::SplitFX.new
44
53
  splitfx.import(YAML.load(File.read(file)), overrides)
45
54
  splitfx.run(target, opts)
@@ -5,7 +5,6 @@
5
5
  # WARNING: totally unstable API, use dtas-ctl for scripting (but the protocol
6
6
  # itself is also unstable, but better than this one probably).
7
7
  require 'dtas/unix_client'
8
- require 'yaml'
9
8
  require 'shellwords'
10
9
 
11
10
  def get_track_ids(c)
@@ -16,6 +15,86 @@ def get_track_ids(c)
16
15
  track_ids
17
16
  end
18
17
 
18
+ def do_edit(c)
19
+ require 'dtas/edit_client'
20
+ require 'yaml'
21
+ require 'tempfile'
22
+ extend DTAS::EditClient
23
+ tmp = Tempfile.new(%w(dtas-tl-edit .txt))
24
+ tmp.binmode
25
+ tmp_path = tmp.path
26
+ orig = []
27
+ orig_idx = {}
28
+
29
+ get_track_ids(c).each_slice(128) do |track_ids|
30
+ res = c.req("tl get #{track_ids.join(' ')}")
31
+ res = Shellwords.split(res.sub!(/\A\d+ /, ''))
32
+ while line = res.shift
33
+ line.sub!(/\A(\d+)=/, '') or abort "unexpected line=#{line.inspect}\n"
34
+ track_id = $1.to_i
35
+ orig_idx[track_id] = orig.size
36
+ orig << track_id
37
+ tmp.write("#{Shellwords.escape(line)} =#{track_id}\n")
38
+ end
39
+ end
40
+ tmp.flush
41
+
42
+ ed = editor
43
+ # jump to the line of the currently playing track if using vi or vim
44
+ # Patches for other editors welcome: dtas-all@nongnu.org
45
+ if ed =~ /vim?\z/
46
+ cur = YAML.load(c.req('current'))
47
+ if tl = cur['tracklist']
48
+ if pos = tl['pos']
49
+ ed += " +#{pos + 1}"
50
+ end
51
+ end
52
+ end
53
+ # Run the editor and let the user edit!
54
+ system("#{ed} #{Shellwords.escape tmp_path}") or return
55
+
56
+ edit = []
57
+ edit_idx = {}
58
+ add = []
59
+ # editor may rename/link a new file into place
60
+ File.open(tmp_path) do |fp|
61
+ while line = fp.gets
62
+ line.chomp!
63
+ if line.sub!(/ =(\d+)\z/, '') # existing tracks
64
+ track_id = $1.to_i
65
+ if edit_idx[track_id] # somebody copy+pasted an existing line
66
+ add << [ File.expand_path(line), edit.last ]
67
+ else # moved line
68
+ edit_idx[track_id] = edit.size
69
+ edit << track_id
70
+ end
71
+ else # entirely new line
72
+ add << [ File.expand_path(line), edit.last ]
73
+ end
74
+ end
75
+ end
76
+ edit.each_with_index do |track_id, i|
77
+ oi = orig_idx[track_id] or warn("unknown track_id=#{track_id}") or next
78
+ next if oi == i # no change, yay!
79
+ prev_track_id = orig[i] or warn("unknown index at #{i}") or next
80
+ c.req("tl swap #{track_id} #{prev_track_id}")
81
+ orig_idx[track_id] = i
82
+ orig_idx[prev_track_id] = oi
83
+ orig[i] = track_id
84
+ orig[oi] = prev_track_id
85
+ end
86
+ orig.each do |track_id|
87
+ edit_idx[track_id] or c.req("tl remove #{track_id}")
88
+ end
89
+ add.each do |path, after_id|
90
+ cmd = %W(tl add #{path})
91
+ cmd << after_id.to_s if after_id
92
+ c.req(cmd)
93
+ end
94
+ ensure
95
+ tmp.close! if tmp
96
+ end
97
+
19
98
  c = DTAS::UNIXClient.new
20
99
  case cmd = ARGV[0]
21
100
  when "cat"
@@ -24,10 +103,6 @@ def get_track_ids(c)
24
103
  res.sub!(/\A1 /, '')
25
104
  print "#{res}\n"
26
105
  end
27
- when "clear"
28
- get_track_ids(c).each do |track_id|
29
- print("#{track_id} " << c.req("tl remove #{track_id}") << "\n")
30
- end
31
106
  when "addhead"
32
107
  ARGV.shift
33
108
  ARGV.reverse.each do |path|
@@ -67,6 +142,7 @@ def get_track_ids(c)
67
142
  end
68
143
  warn "#{re.inspect} not found"
69
144
  exit 1
145
+ when 'edit' then do_edit(c)
70
146
  else
71
147
  # act like dtas-ctl for now...
72
148
  puts c.req([ "tl", *ARGV ])
@@ -3,7 +3,7 @@
3
3
  Gem::Specification.new do |s|
4
4
  manifest = File.read('.gem-manifest').split(/\n/)
5
5
  s.name = %q{dtas}
6
- s.version = ENV["VERSION"]
6
+ s.version = ENV["VERSION"].dup
7
7
  s.authors = ["dtas hackers"]
8
8
  s.summary = "duct tape audio suite for *nix"
9
9
  s.description = File.read("README").split(/\n\n/)[1].strip
@@ -11,5 +11,5 @@
11
11
  s.executables = manifest.grep(%r{\Abin/}).map { |s| s.sub(%r{\Abin/}, "") }
12
12
  s.files = manifest
13
13
  s.homepage = 'http://dtas.80x24.org/'
14
- s.licenses = "GPLv3+"
14
+ s.licenses = "GPL-3.0+"
15
15
  end
@@ -1,6 +1,23 @@
1
1
  # Copyright (C) 2013-2015 all contributors <dtas-all@nongnu.org>
2
2
  # License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
3
3
  module DTAS # :nodoc:
4
+ # try to use the monotonic clock in Ruby >= 2.1, it is immune to clock
5
+ # offset adjustments and generates less garbage (Float vs Time object)
6
+ begin
7
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
8
+ def self.now
9
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
10
+ end
11
+ rescue NameError, NoMethodError
12
+ def self.now # Ruby <= 2.0
13
+ Time.now.to_f
14
+ end
15
+ end
16
+
17
+ @null = nil
18
+ def self.null
19
+ @null ||= File.open('/dev/null', 'r+')
20
+ end
4
21
  end
5
22
 
6
23
  require_relative 'dtas/compat_onenine'
@@ -3,6 +3,7 @@
3
3
  require 'io/nonblock'
4
4
  require_relative '../../dtas'
5
5
  require_relative '../pipe'
6
+ require_relative '../nonblock'
6
7
 
7
8
  # compatibility code for systems lacking "splice" support via the
8
9
  # "io-splice" RubyGem. Used only by -player
@@ -28,14 +29,12 @@ def discard(bytes)
28
29
  # always block when we have a single target
29
30
  def broadcast_one(targets, limit = nil)
30
31
  buf = _rbuf
31
- @to_io.read_nonblock(limit || MAX_AT_ONCE, buf)
32
+ case rv = @to_io.read_nonblock(limit || MAX_AT_ONCE, buf, exception: false)
33
+ when nil, :wait_readable then return rv
34
+ end
32
35
  n = targets[0].write(buf) # IO#write has write-in-full behavior
33
36
  @bytes_xfer += n
34
37
  :wait_readable
35
- rescue EOFError
36
- nil
37
- rescue Errno::EAGAIN
38
- :wait_readable
39
38
  rescue Errno::EPIPE, IOError => e
40
39
  __dst_error(targets[0], e)
41
40
  targets.clear
@@ -71,15 +70,16 @@ def broadcast_inf(targets, limit = nil)
71
70
  targets.delete_if do |dst|
72
71
  begin
73
72
  if dst.nonblock?
74
- w = dst.write_nonblock(buf)
75
- again[dst] = buf.byteslice(w, n) if w < n
73
+ case w = dst.write_nonblock(buf, exception: false)
74
+ when :wait_writable
75
+ blocked << dst
76
+ else
77
+ again[dst] = buf.byteslice(w, n) if w < n
78
+ end
76
79
  else
77
80
  dst.write(buf)
78
81
  end
79
82
  false
80
- rescue Errno::EAGAIN
81
- blocked << dst
82
- false
83
83
  rescue IOError, Errno::EPIPE => e
84
84
  again.delete(dst)
85
85
  __dst_error(dst, e)
@@ -90,17 +90,19 @@ def broadcast_inf(targets, limit = nil)
90
90
  # try to write as much as possible
91
91
  again.delete_if do |dst, sbuf|
92
92
  begin
93
- w = dst.write_nonblock(sbuf)
94
- n = sbuf.bytesize
95
- if w < n
96
- again[dst] = sbuf.byteslice(w, n)
97
- false
98
- else
93
+ case w = dst.write_nonblock(sbuf, exception: false)
94
+ when :wait_writable
95
+ blocked << dst
99
96
  true
97
+ else
98
+ n = sbuf.bytesize
99
+ if w < n
100
+ again[dst] = sbuf.byteslice(w, n)
101
+ false
102
+ else
103
+ true
104
+ end
100
105
  end
101
- rescue Errno::EAGAIN
102
- blocked << dst
103
- true
104
106
  rescue IOError, Errno::EPIPE => e
105
107
  __dst_error(dst, e)
106
108
  true
@@ -10,7 +10,6 @@ module DTAS::Buffer::Splice # :nodoc:
10
10
  MAX_AT_ONCE = 4096 # page size in Linux
11
11
  MAX_AT_ONCE_1 = 65536
12
12
  MAX_SIZE = File.read("/proc/sys/fs/pipe-max-size").to_i
13
- DEVNULL = File.open("/dev/null", "r+")
14
13
  F_MOVE = IO::Splice::F_MOVE
15
14
 
16
15
  def buffer_size
@@ -25,7 +24,7 @@ def buffer_size=(bytes)
25
24
 
26
25
  # be sure to only call this with nil when all writers to @wr are done
27
26
  def discard(bytes)
28
- IO.splice(@to_io, nil, DEVNULL, nil, bytes)
27
+ IO.splice(@to_io, nil, DTAS.null, nil, bytes)
29
28
  end
30
29
 
31
30
  def broadcast_one(targets, limit = nil)
@@ -44,9 +44,9 @@ def self.load(hash)
44
44
 
45
45
  def self.precision(env, infile)
46
46
  # sox.git f4562efd0aa3
47
- qx(env, %W(soxi -p #{infile}), err: "/dev/null").to_i
47
+ qx(env, %W(soxi -p #{infile}), err: DTAS.null).to_i
48
48
  rescue # fallback to parsing the whole output
49
- s = qx(env, %W(soxi #{infile}), err: "/dev/null")
49
+ s = qx(env, %W(soxi #{infile}), err: DTAS.null)
50
50
  s =~ /Precision\s+:\s*(\d+)-bit/n
51
51
  v = $1.to_i
52
52
  return v if v > 0
@@ -0,0 +1,500 @@
1
+ # -*- encoding: utf-8 -*-
2
+ # Copyright (C) 2015 all contributors <dtas-all@nongnu.org>
3
+ # License: GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.txt)
4
+
5
+ require_relative '../dtas'
6
+ require_relative 'process'
7
+ require 'socket'
8
+
9
+ # For the DTAS Music Library, based on what MPD uses.
10
+ class DTAS::Mlib
11
+ attr_accessor :follow_outside_symlinks
12
+ attr_accessor :follow_inside_symlinks
13
+ attr_accessor :tags
14
+
15
+ DM_DIR = -1
16
+ DM_IGN = -2
17
+ include DTAS::Process
18
+
19
+ Job = Struct.new(:wd, :ctime, :parent_id, :path)
20
+
21
+ # same capitalization as in mpd
22
+ TAGS = Hash[*(
23
+ %w(Artist ArtistSort
24
+ Album AlbumSort
25
+ AlbumArtist AlbumArtistSort
26
+ Title Track Name
27
+ Genre Date Composer Performer Comment Disc
28
+ MUSICBRAINZ_ARTISTID MUSICBRAINZ_ALBUMID
29
+ MUSICBRAINZ_ALBUMARTISTID
30
+ MUSICBRAINZ_TRACKID
31
+ MUSICBRAINZ_RELEASETRACKID).map! { |x| [ x.downcase, x ] }.flatten!)]
32
+
33
+ def initialize(db)
34
+ if String === db
35
+ db = "sqlite://#{db}" unless db.include?('://')
36
+ require 'sequel/no_core_ext'
37
+ db = Sequel.connect(db, single_threaded: true)
38
+ end
39
+ if db.class.to_s.downcase.include?('sqlite')
40
+ db.transaction_mode = :immediate
41
+ db.synchronous = :off
42
+ db.case_sensitive_like = false
43
+ end
44
+ @db = db
45
+ @pwd = nil
46
+ @follow_outside_symlinks = true
47
+ @follow_inside_symlinks = true
48
+ @root_node = nil
49
+ @tags = TAGS.dup
50
+ @tag_map = nil
51
+ @suffixes = nil
52
+ @work = nil
53
+ end
54
+
55
+ def init_suffixes
56
+ `sox --help 2>/dev/null` =~ /\nAUDIO FILE FORMATS:\s*([^\n]+)/s
57
+ re = $1.split(/\s+/).map { |x| Regexp.quote(x) }.join('|')
58
+ @suffixes = Regexp.new("\\.(?:#{re})\\z", Regexp::IGNORECASE)
59
+ end
60
+
61
+ def worker(todo)
62
+ @work.close
63
+ @db.tables # reconnect before chdir
64
+ @pwd = Dir.pwd.b
65
+ begin
66
+ buf = todo.recv(16384) # 4x bigger than PATH_MAX ought to be enough
67
+ exit if buf.empty?
68
+ job = Marshal.load(buf)
69
+ buf.clear
70
+ worker_work(job)
71
+ rescue => e
72
+ warn "#{e.message} (#{e.class}) #{e.backtrace.join("\n")}\n"
73
+ end while true
74
+ end
75
+
76
+ def ignore(job)
77
+ @db.transaction do
78
+ node_ensure(job.parent_id, job.path, DM_IGN, job.ctime)
79
+ end
80
+ end
81
+
82
+ def worker_work(job)
83
+ tlen = nil
84
+ wd = job.wd
85
+ if wd != @pwd
86
+ Dir.chdir(wd)
87
+ @pwd = wd
88
+ end
89
+ tmp = {}
90
+ path = job.path
91
+ tlen = qx(%W(soxi -D #{path}), no_raise: true)
92
+ return ignore(job) unless String === tlen
93
+ tlen = tlen.to_f
94
+ return ignore(job) if tlen < 0
95
+ tlen = tlen.round
96
+ buf = qx(%W(soxi -a #{path}), no_raise: true)
97
+ return ignore(job) unless String === buf
98
+
99
+ # no, we don't support comments with newlines in them
100
+ buf = buf.split("\n".freeze)
101
+ while line = buf.shift
102
+ tag, value = line.split('='.freeze, 2)
103
+ tag && value or next
104
+ tag.downcase!
105
+ tag_id = @tag_map[tag] or next
106
+ value.strip!
107
+
108
+ # FIXME: this fallback needs testing
109
+ [ Encoding::UTF_8, Encoding::ISO_8859_1 ].each do |enc|
110
+ value.force_encoding(enc)
111
+ if value.valid_encoding?
112
+ value.encode!(Encoding::UTF_8) if enc != Encoding::UTF_8
113
+ tmp[tag_id] = value
114
+ break
115
+ end
116
+ end
117
+ end
118
+ @db.transaction do
119
+ node_id = node_ensure(job.parent_id, path, tlen, job.ctime)[:id]
120
+ vals = @db[:vals]
121
+ comments = @db[:comments]
122
+ q = { node_id: node_id }
123
+ comments.where(q).delete
124
+ tmp.each do |tid, val|
125
+ v = vals[val: val]
126
+ q[:val_id] = v ? v[:id] : vals.insert(val: val)
127
+ q[:tag_id] = tid
128
+ comments.insert(q)
129
+ end
130
+ end
131
+ end
132
+
133
+ def update(path, opts = nil)
134
+ # n.b. "jobs" is for CPU concurrency. Audio media is typically stored
135
+ # on high-latency media or slow network file systems; so we use a high
136
+ # number of jobs by default to compensate for the seek-heavy workload
137
+ # this generates
138
+ opts ||= {}
139
+ jobs = opts[:jobs] || 8
140
+
141
+ init_suffixes
142
+ st = File.stat(path) # we always follow the first dir even if it's a symlink
143
+ st.directory? or
144
+ raise ArgumentError, "path: #{path.inspect} is not a directory"
145
+ @work and raise 'update already running'
146
+ todo, @work = UNIXSocket.pair(:SOCK_SEQPACKET)
147
+ @db.disconnect
148
+ jobs.times { |i| fork { worker(todo) } }
149
+ todo.close
150
+ scan_dir(path, st)
151
+ @work.close
152
+ Process.waitall
153
+ ensure
154
+ @work = nil
155
+ end
156
+
157
+ def migrate
158
+ require 'sequel'
159
+ Sequel.extension(:migration, :core_extensions) # ugh...
160
+ @db.transaction do
161
+ Sequel::Migrator.apply(@db, "#{File.dirname(__FILE__)}/mlib/migrations")
162
+ root_node # ensure this exists
163
+ load_tags
164
+ end
165
+ end
166
+
167
+ def load_tags
168
+ return @tag_map if @tag_map
169
+ tag_map = {}
170
+ tags = @db[:tags]
171
+ @tags.each do |lc, mc|
172
+ unless q = tags[tag: mc]
173
+ q = { tag: mc }
174
+ q[:id] = tags.insert(q)
175
+ end
176
+ tag_map[lc] = q[:id]
177
+ end
178
+
179
+ # Xiph tags use "tracknumber" and "discnumber"
180
+ %w(track disc).each do |x|
181
+ tag_id = tag_map[x] and tag_map["#{x}number"] = tag_id
182
+ end
183
+ @tag_map = tag_map.freeze
184
+ end
185
+
186
+ def scan_any(path, parent_id)
187
+ st = File.lstat(path) rescue return
188
+ if st.directory?
189
+ scan_dir(path, st, parent_id)
190
+ elsif st.file?
191
+ scan_file(path, st, parent_id)
192
+ # elsif st.symlink? TODO
193
+ # scan_link(path, st, parent_id)
194
+ end
195
+ end
196
+
197
+ def scan_file(path, st, parent_id)
198
+ return if @suffixes !~ path || st.size == 0
199
+
200
+ # no-op if no change
201
+ if node = @db[:nodes][name: path, parent_id: parent_id]
202
+ return if st.ctime.to_i == node[:ctime] || node[:tlen] == DM_IGN
203
+ end
204
+
205
+ job = Job.new(@pwd, st.ctime.to_i, parent_id, path)
206
+ send_harder(@work, Marshal.dump(job))
207
+ end
208
+
209
+ def root_node
210
+ q = @root_node and return q
211
+ # root node always has parent_id: 1
212
+ q = {
213
+ parent_id: 1, # self
214
+ name: '',
215
+ }
216
+ node = @db[:nodes][q] and return (@root_node = node)
217
+ begin
218
+ q[:tlen] = DM_DIR
219
+ q[:id] = @db[:nodes].insert(q)
220
+ q
221
+ rescue Sequel::DatabaseError
222
+ # we may conflict on insert if we didn't use a transaction
223
+ raise if @db.in_transaction?
224
+ @root_node = @db[:paths][q] or raise
225
+ end
226
+ end
227
+
228
+ def dir_vivify(parts, ctime)
229
+ @db.transaction do
230
+ dir = root_node
231
+ last = parts.pop
232
+ parts.each do |name|
233
+ dir = node_ensure(dir[:id], name, DM_DIR)
234
+ end
235
+ node_ensure(dir[:id], last, DM_DIR, ctime)
236
+ end
237
+ end
238
+
239
+ def node_update_maybe(node, tlen, ctime)
240
+ q = {}
241
+ q[:ctime] = ctime if ctime && ctime != node[:ctime]
242
+ q[:tlen] = tlen if tlen != node[:tlen]
243
+ return if q.empty?
244
+ node_id = node.delete(:id)
245
+ @db[:nodes].where(id: node_id).update(node.merge(q))
246
+ node[:id] = node_id
247
+ end
248
+
249
+ def node_lookup(parent_id, name)
250
+ @db[:nodes][name: name, parent_id: parent_id]
251
+ end
252
+
253
+ def node_ensure(parent_id, name, tlen, ctime = nil)
254
+ q = { name: name, parent_id: parent_id }
255
+ if node = @db[:nodes][q]
256
+ node_update_maybe(node, tlen, ctime)
257
+ else
258
+ # brand new node
259
+ node = q.dup
260
+ node[:tlen] = tlen
261
+ node[:ctime] = ctime
262
+ node[:id] = @db[:nodes].insert(node)
263
+ end
264
+ node
265
+ end
266
+
267
+ def cd(path)
268
+ prev_wd = @pwd
269
+ Dir.chdir(path)
270
+ cur = @pwd = Dir.pwd.b
271
+ yield
272
+ ensure
273
+ Dir.chdir(prev_wd) if cur && prev_wd
274
+ @pwd = prev_wd
275
+ end
276
+
277
+ def scan_dir(path, st, parent_id = nil)
278
+ cd(path) do
279
+ # TODO: use parent_id if given
280
+ dir = dir_vivify(@pwd.split(%r{/+}n), st.ctime.to_i)
281
+ dir_id = dir[:id]
282
+
283
+ @db[:nodes].where(parent_id: dir_id).each do |node|
284
+ File.exist?(node[:name]) or remove_entry(node)
285
+ end
286
+
287
+ Dir.foreach('.', encoding: Encoding::BINARY) do |x|
288
+ case x
289
+ when '.', '..', %r{\n}n
290
+ # files with newlines in them are rare and last I checked (in 2008),
291
+ # mpd could not support them, either. So lets not bother for now.
292
+ next
293
+ else
294
+ scan_any(x, dir_id)
295
+ end
296
+ end
297
+ end
298
+ end
299
+
300
+ def send_harder(sock, msg)
301
+ sock.sendmsg(msg)
302
+ rescue Errno::EMSGSIZE
303
+ sock.setsockopt(:SOL_SOCKET, :SO_SNDBUF, msg.bytesize + 1024)
304
+ # if it still fails, oh well...
305
+ begin
306
+ sock.sendmsg(msg)
307
+ rescue => e
308
+ warn "#{msg.bytesize} too big, dropped #{e.class}"
309
+ end
310
+ end
311
+
312
+ def find_dump_part(cur, base)
313
+ parts = @pwd.split(%r{/+}n)
314
+ parts.shift # no first part
315
+ parts << base if base
316
+ parts.each do |name|
317
+ if cur = node_lookup(cur[:id], name)
318
+ case cur[:tlen]
319
+ when DM_DIR then next # keep going
320
+ when DM_IGN then return [ :ignored, cur ]
321
+ else # regular audio
322
+ return cur if name.object_id == parts[-1].object_id
323
+ return [ :notdir, cur ]
324
+ end
325
+ else
326
+ return [ :missing, name ]
327
+ end
328
+ end
329
+ cur
330
+ end
331
+
332
+ # returns an array on error
333
+ def dump(path, cache, cb)
334
+ dir = path
335
+ base = nil
336
+ retried = false
337
+ begin
338
+ found = cd(dir) { find_dump_part(root_node, base) }
339
+ rescue Errno::ENOTDIR
340
+ raise if retried || found
341
+ dir, base = File.split(path)
342
+ retried = true
343
+ retry
344
+ end
345
+ return found if Array === found # error
346
+
347
+ # success
348
+ load_tags
349
+ @tag_rmap = @tag_map.invert
350
+ if found[:tlen] == DM_DIR
351
+ emit_recurse(found, cache, cb)
352
+ else
353
+ parent = @db[:nodes][id: found[:parent_id]]
354
+ parent or abort "missing parent for #{found.inspect}"
355
+ parent[:dirname] ||= path_of(parent, cache)
356
+ emit_1(found, parent, cache, cb)
357
+ end
358
+ end
359
+
360
+ def count_distinct(tag)
361
+ s = 'SELECT COUNT(DISTINCT(val_id)) FROM comments WHERE tag_id = ?'
362
+ @db.fetch(s, @tag_map[tag]).single_value
363
+ end
364
+
365
+ def count_songs
366
+ @db.fetch('SELECT COUNT(*) FROM nodes WHERE tlen >= 0').single_value
367
+ end
368
+
369
+ def db_playtime
370
+ @db.fetch('SELECT SUM(tlen) FROM nodes WHERE tlen >= 0').single_value
371
+ end
372
+
373
+ def stats
374
+ rv = { songs: count_songs, db_playtime: db_playtime }
375
+ %w(artist album).each { |k| rv[:"#{k}s"] = count_distinct(k) }
376
+ rv
377
+ end
378
+
379
+ def path_of(node, cache)
380
+ base = node[:name]
381
+ return '/' if base == ''
382
+ parent_id = node[:parent_id]
383
+ base += '/' unless node[:tlen] >= 0
384
+ ppath = cache[parent_id] and return "#{ppath}#{base}"
385
+ parts = []
386
+ begin
387
+ node = @db[:nodes][id: node[:parent_id]]
388
+ break if node[:id] == node[:parent_id]
389
+ parts.unshift node[:name]
390
+ end while true
391
+ parts.unshift('')
392
+ parts << base
393
+ cache[parent_id] = parts.join('/')
394
+ end
395
+
396
+ def emit_recurse(node, cache, cb)
397
+ node[:dirname] ||= path_of(node, cache)
398
+ @db[:nodes].where(parent_id: node[:id]).order(:name).each do |nd|
399
+ next if nd[:id] == node[:id] # root_node
400
+ case nd[:tlen]
401
+ when DM_DIR then emit_recurse(nd, cache, cb)
402
+ when DM_IGN then next
403
+ else
404
+ emit_1(nd, node, cb)
405
+ end
406
+ end
407
+ end
408
+
409
+ def emit_1(node, parent, cb)
410
+ comments = Hash.new { |h,k| h[k] = [] }
411
+ @db['SELECT c.tag_id, v.val FROM comments c ' \
412
+ 'LEFT JOIN vals v ON v.id = c.val_id ' \
413
+ "WHERE c.node_id = #{node[:id]} ORDER BY c.tag_id"].map do |c|
414
+ comments[@tag_rmap[c[:tag_id]]] << c[:val]
415
+ end
416
+ cb.call(parent, node, comments)
417
+ end
418
+
419
+ def remove_entry(node)
420
+ root_id = root_node[:id]
421
+ node_id = node[:id]
422
+ q = { parent_id: node_id }
423
+ nodes = @db[:nodes]
424
+ nodes.where(q).each do |nd|
425
+ next if nd[:id] == root_id
426
+ case nd[:tlen]
427
+ when DM_DIR, DM_IGN
428
+ remove_entry(nd)
429
+ end
430
+ end
431
+ nodes.where(q).delete
432
+ @db[:comments].where(node_id: node_id).delete
433
+ nodes.where(id: node_id).delete
434
+ end
435
+
436
+ def offset_limit(q, offset, limit)
437
+ offset = offset.to_s
438
+ limit = limit.to_s
439
+ if limit =~ %r{\A\d+\z}
440
+ q << "LIMIT #{limit}"
441
+ q << "OFFSET #{offset}" if offset =~ %r{\A\d+\z} && offset != '0'
442
+ end
443
+ q
444
+ end
445
+
446
+ # based on the MPD command of the same name, unstable API
447
+ def find(type, what, offset = 0, limit = nil)
448
+ load_tags
449
+ type = type.downcase
450
+ q = []
451
+ case type
452
+ when 'any'
453
+ # TODO: add path name matches
454
+ q << 'SELECT DISTINCT(n.id),n.* FROM nodes n ' \
455
+ 'LEFT JOIN comments c ON c.node_id = n.id ' \
456
+ 'LEFT JOIN vals v ON v.id = c.val_id ' \
457
+ 'WHERE v.val = ?'
458
+ when *(@tags.keys)
459
+ tag_id = @tag_map[type]
460
+ q << 'SELECT DISTINCT(n.id),n.* FROM nodes n ' \
461
+ 'LEFT JOIN comments c ON c.node_id = n.id ' \
462
+ 'LEFT JOIN vals v ON v.id = c.val_id ' \
463
+ 'LEFT JOIN tags t ON t.id = c.tag_id ' \
464
+ "WHERE v.val = ? AND t.id = #{tag_id}"
465
+ else
466
+ raise ArgumentError, "invalid type=#{type.inspect}"
467
+ end
468
+ q << 'ORDER by n.parent_id,n.name'
469
+ offset_limit(q, offset, limit)
470
+ @db[q.join(' '), what].each { |node| yield node }
471
+ end
472
+
473
+ # based on the MPD command of the same name
474
+ def search(type, what, offset = 0, limit = nil)
475
+ load_tags
476
+ type = type.downcase
477
+ q = []
478
+ what = @db.literal(%Q(%#{what}%))
479
+ case type
480
+ when 'any'
481
+ # TODO: add path name matches
482
+ q << 'SELECT DISTINCT(n.id),n.* FROM nodes n ' \
483
+ 'LEFT JOIN comments c ON c.node_id = n.id ' \
484
+ 'LEFT JOIN vals v ON v.id = c.val_id ' \
485
+ "WHERE v.val LIKE #{what}"
486
+ when *(@tags.keys)
487
+ tag_id = @tag_map[type]
488
+ q << 'SELECT DISTINCT(n.id),n.* FROM nodes n ' \
489
+ 'LEFT JOIN comments c ON c.node_id = n.id ' \
490
+ 'LEFT JOIN vals v ON v.id = c.val_id ' \
491
+ 'LEFT JOIN tags t ON t.id = c.tag_id ' \
492
+ "WHERE t.id = #{tag_id} AND v.val LIKE #{what}"
493
+ else
494
+ raise ArgumentError, "invalid type=#{type.inspect}"
495
+ end
496
+ q << 'ORDER by n.parent_id,n.name'
497
+ offset_limit(q, offset, limit)
498
+ @db[q.join(' ')].each { |node| yield node }
499
+ end
500
+ end