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.
- checksums.yaml +4 -4
- data/.gitattributes +4 -0
- data/Documentation/GNUmakefile +1 -1
- data/Documentation/dtas-console.txt +1 -0
- data/Documentation/dtas-player_protocol.txt +19 -5
- data/Documentation/dtas-splitfx.txt +13 -0
- data/Documentation/dtas-tl.txt +16 -0
- data/GIT-VERSION-GEN +1 -1
- data/GNUmakefile +2 -2
- data/INSTALL +3 -3
- data/README +4 -0
- data/bin/dtas-archive +5 -1
- data/bin/dtas-console +13 -6
- data/bin/dtas-cueedit +1 -1
- data/bin/dtas-mlib +47 -0
- data/bin/dtas-readahead +211 -0
- data/bin/dtas-sinkedit +1 -1
- data/bin/dtas-sourceedit +1 -1
- data/bin/dtas-splitfx +15 -6
- data/bin/dtas-tl +81 -5
- data/dtas.gemspec +2 -2
- data/lib/dtas.rb +17 -0
- data/lib/dtas/buffer/read_write.rb +21 -19
- data/lib/dtas/buffer/splice.rb +1 -2
- data/lib/dtas/format.rb +2 -2
- data/lib/dtas/mlib.rb +500 -0
- data/lib/dtas/mlib/migrations/0001_initial.rb +42 -0
- data/lib/dtas/nonblock.rb +24 -0
- data/lib/dtas/parse_freq.rb +29 -0
- data/lib/dtas/parse_time.rb +5 -2
- data/lib/dtas/pipe.rb +2 -1
- data/lib/dtas/player.rb +21 -41
- data/lib/dtas/player/client_handler.rb +175 -92
- data/lib/dtas/process.rb +41 -17
- data/lib/dtas/sigevent/pipe.rb +6 -5
- data/lib/dtas/sink.rb +1 -1
- data/lib/dtas/source/splitfx.rb +14 -0
- data/lib/dtas/splitfx.rb +52 -36
- data/lib/dtas/track.rb +13 -0
- data/lib/dtas/tracklist.rb +148 -43
- data/lib/dtas/unix_accepted.rb +49 -32
- data/lib/dtas/unix_client.rb +1 -1
- data/lib/dtas/unix_server.rb +17 -9
- data/lib/dtas/watchable.rb +16 -5
- data/test/test_env.rb +16 -0
- data/test/test_mlib.rb +31 -0
- data/test/test_parse_freq.rb +18 -0
- data/test/test_player_client_handler.rb +12 -12
- data/test/test_splitfx.rb +0 -29
- data/test/test_tracklist.rb +75 -17
- data/test/test_unixserver.rb +0 -11
- metadata +16 -4
data/bin/dtas-sinkedit
CHANGED
data/bin/dtas-sourceedit
CHANGED
data/bin/dtas-splitfx
CHANGED
@@ -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') {
|
16
|
-
op.on('-D', '--no-dither') {
|
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')
|
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 ||
|
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)
|
data/bin/dtas-tl
CHANGED
@@ -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 ])
|
data/dtas.gemspec
CHANGED
@@ -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 = "
|
14
|
+
s.licenses = "GPL-3.0+"
|
15
15
|
end
|
data/lib/dtas.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
95
|
-
|
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
|
data/lib/dtas/buffer/splice.rb
CHANGED
@@ -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,
|
27
|
+
IO.splice(@to_io, nil, DTAS.null, nil, bytes)
|
29
28
|
end
|
30
29
|
|
31
30
|
def broadcast_one(targets, limit = nil)
|
data/lib/dtas/format.rb
CHANGED
@@ -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:
|
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:
|
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
|
data/lib/dtas/mlib.rb
ADDED
@@ -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
|