srctree

Andrew Kelley parent c028da46 2de30e69
scan and analyze all files on startup

Now the server has these additional things for all songs: * true peak * integrated loudness * true duration * waveform image data Fixes wrong duration sometimes. Important since correct client behavior depends on knowing accurate duration.
example/analyze.zig added: 198, removed: 116, total 82
@@ -52,7 +52,7 @@ fn runAnalysis(file_path: [:0]const u8, result: *player.Analysis) !void {
 
const analysis = try file.analyze(1920);
std.log.info("duration: {d}, true peak: {d}, integrated loudness: {d}", .{
analysis.duration,
analysis.duration.toFloat(f64),
analysis.true_peak,
analysis.integrated_loudness,
});
 
player/root.zig added: 198, removed: 116, total 82
@@ -470,10 +470,6 @@ pub const File = struct {
 
assert(total_frames == channels[0].totalSamples());
 
std.log.debug("channel clusters bytes: {d}", .{
channels[0].clusters.items.len * 2 * @sizeOf(HiResSampleCluster),
});
 
var twi_encoder = Twi.Encoder.init;
try twi_encoder.setCapacity(gpa, waveform_image_width);
defer twi_encoder.deinit(gpa);
@@ -491,7 +487,10 @@ pub const File = struct {
}
 
return .{
.duration = @as(f64, @floatFromInt(total_frames)) / sample_rate,
.duration = .{
.sample_count = @intCast(total_frames),
.sample_rate = sample_rate,
},
.true_peak = try ebur128_ctx.opt_get_double("true_peak"),
.integrated_loudness = try ebur128_ctx.opt_get_double("integrated"),
.twi = twi_encoder.toOwned(),
@@ -518,7 +517,7 @@ pub const File = struct {
};
 
pub const Analysis = struct {
duration: f64,
duration: Duration,
true_peak: f64,
integrated_loudness: f64,
twi: Twi,
 
server/main.zig added: 198, removed: 116, total 82
@@ -100,15 +100,37 @@ pub fn main() anyerror!noreturn {
try loadDb(gpa, &db, install_directory, config.db_path);
 
{
var it = config.music_directory.handle.iterate();
try scanDir(
&db,
gpa,
var scan_arena = std.heap.ArenaAllocator.init(gpa);
defer scan_arena.deinit();
 
var scan: Scan = .{
.gpa = gpa,
.arena = scan_arena.allocator(),
.db = &db,
.mutex = .{},
.thread_pool = &thread_pool,
.wait_group = .{},
.new_count = 0,
};
defer {
scan.wait_group.wait();
std.log.info("finished scanning {d} new files", .{scan.new_count});
}
 
scan.mutex.lock();
defer scan.mutex.unlock();
 
var rcd: Scan.RefCountedDir = .{
.dir = config.music_directory.handle,
.count = 1,
.it = config.music_directory.handle.iterate(),
};
try scan.dir(
try db.addDirectory(gpa, .{
.parent = .none,
.basename = .empty,
}),
&it,
&rcd,
);
}
 
@@ -451,109 +473,168 @@ pub const Server = struct {
}
};
 
fn scanDir(db: *Db, gpa: Allocator, db_dir: Db.Path.Index, it: *std.fs.Dir.Iterator) anyerror!void {
while (try it.next()) |entry| switch (entry.kind) {
.directory => {
var sub_dir = it.dir.openDir(entry.name, .{ .iterate = true }) catch |err| {
std.log.err("while scanning, unable to open directory '{s}': {s}", .{
entry.name, @errorName(err),
});
continue;
};
defer sub_dir.close();
const basename = try db.getOrPutString(gpa, entry.name);
var sub_it = sub_dir.iterateAssumeFirstIteration();
try scanDir(
db,
gpa,
try db.addDirectory(gpa, .{
.parent = db_dir.toOptional(),
.basename = basename,
}),
&sub_it,
);
},
.sym_link => {
var sub_dir = it.dir.openDir(entry.name, .{ .iterate = true }) catch |err| {
std.log.err("while scanning, unable to open symlink '{s}' as a directory: {s}", .{
entry.name, @errorName(err),
});
continue;
};
defer sub_dir.close();
const basename = try db.getOrPutString(gpa, entry.name);
var sub_it = sub_dir.iterateAssumeFirstIteration();
try scanDir(
db,
gpa,
try db.addDirectory(gpa, .{
.parent = db_dir.toOptional(),
.basename = basename,
}),
&sub_it,
);
},
.file => {
const basename = try db.getOrPutString(gpa, entry.name);
const file_gop = try db.files.getOrPut(gpa, .{
.directory = db_dir,
.basename = basename,
.title = undefined,
.artist = undefined,
.album = undefined,
.track_number = undefined,
.composer = undefined,
.performer = undefined,
.duration = undefined,
});
if (file_gop.found_existing) continue;
db.files.lockPointers();
defer db.files.unlockPointers();
const Scan = struct {
gpa: Allocator,
arena: Allocator,
db: *Db,
mutex: std.Thread.Mutex,
thread_pool: *std.Thread.Pool,
wait_group: std.Thread.WaitGroup,
new_count: u32,
 
const basename_z = db.stringToSlice(basename);
std.log.debug("opening new file '{s}'", .{basename_z});
const file = player.File.open(it.dir, basename_z, basename_z) catch |err| {
std.log.warn("unable to open '{s}': {s}", .{ entry.name, @errorName(err) });
continue;
};
defer file.close();
const RefCountedDir = struct {
dir: std.fs.Dir,
count: u32,
it: std.fs.Dir.Iterator,
 
const metadata = try fileMetaData(gpa, db, file);
fn ref(rcd: *RefCountedDir) void {
_ = @atomicRmw(u32, &rcd.count, .Add, 1, .monotonic);
}
 
const album: Db.Album.Index = a: {
const gop = try db.albums.getOrPut(gpa, .{
.title = metadata.album,
.year = metadata.year,
.track_count = metadata.track_count,
.disc_number = metadata.disc_number,
.disc_count = metadata.disc_count,
.artist = Db.Album.ArtistIndex.init(metadata.album_artist, db, metadata.compilation),
});
break :a @enumFromInt(gop.index);
};
 
file_gop.key_ptr.* = .{
.directory = db_dir,
.basename = basename,
.title = metadata.title.unwrap() orelse basename,
.artist = metadata.artist,
.album = album,
.track_number = metadata.track_number,
.composer = metadata.composer,
.performer = metadata.performer,
.duration = metadata.duration,
};
},
else => {
std.log.info("ignoring {s} '{/}{s}'", .{
@tagName(entry.kind),
db.fmtPath(db.directory(db_dir)),
entry.name,
});
continue;
},
fn deref(rcd: *RefCountedDir) void {
switch (@atomicRmw(u32, &rcd.count, .Sub, 1, .monotonic)) {
0 => unreachable, // too many derefs
1 => rcd.dir.close(),
else => {},
}
}
};
}
 
/// Assumes DB lock is held.
fn dir(scan: *Scan, db_dir: Db.Path.Index, rcd_parent: *RefCountedDir) anyerror!void {
const db = scan.db;
const gpa = scan.gpa;
const arena = scan.arena;
 
while (try rcd_parent.it.next()) |entry| switch (entry.kind) {
.directory, .sym_link => {
const rcd = try arena.create(RefCountedDir);
 
var sub_dir = rcd_parent.it.dir.openDir(entry.name, .{ .iterate = true }) catch |err| {
std.log.err("while scanning, unable to open directory '{s}': {s}", .{
entry.name, @errorName(err),
});
continue;
};
rcd.* = .{
.dir = sub_dir,
.count = 1,
.it = sub_dir.iterateAssumeFirstIteration(),
};
defer rcd.deref();
 
const basename = try db.getOrPutString(gpa, entry.name);
const db_sub_dir = try db.addDirectory(gpa, .{
.parent = db_dir.toOptional(),
.basename = basename,
});
 
try dir(scan, db_sub_dir, rcd);
},
.file => {
const basename = try db.getOrPutString(gpa, entry.name);
 
const gop = try db.files.getOrPut(gpa, .{
.directory = db_dir,
.basename = basename,
.title = undefined,
.artist = undefined,
.album = undefined,
.track_number = undefined,
.composer = undefined,
.performer = undefined,
.duration = undefined,
.true_peak = undefined,
.integrated_loudness = undefined,
});
if (gop.found_existing) continue;
 
const basename_z = try arena.dupeZ(u8, entry.name);
scan.new_count += 1;
 
scan.mutex.unlock();
defer scan.mutex.lock();
 
rcd_parent.ref();
const file_index: Db.File.Index = @enumFromInt(gop.index);
scan.thread_pool.spawnWg(&scan.wait_group, analyzeFile, .{
scan, rcd_parent, basename_z, file_index,
});
},
else => {
std.log.info("ignoring {s} '{/}{s}'", .{
@tagName(entry.kind),
db.fmtPath(db.directory(db_dir)),
entry.name,
});
continue;
},
};
}
 
/// This function is executed in a thread pool.
fn analyzeFile(scan: *Scan, rcd: *RefCountedDir, basename_z: [:0]const u8, file_index: Db.File.Index) void {
const gpa = scan.gpa;
const db = scan.db;
defer rcd.deref();
 
// DB lock is not held here.
 
std.log.debug("opening new file '{s}'", .{basename_z});
const file = player.File.open(rcd.dir, basename_z, basename_z) catch |err| {
std.log.warn("unable to open '{s}': {s}", .{ basename_z, @errorName(err) });
return;
};
defer file.close();
 
const analysis = file.analyze(1920) catch |err| {
std.log.err("unable to analyze '{s}': {s}", .{ basename_z, @errorName(err) });
return;
};
 
scan.mutex.lock();
defer scan.mutex.unlock();
 
const metadata = fileMetaData(gpa, db, file) catch |err| {
std.log.err("unable to extract metadata from '{s}': {s}", .{ basename_z, @errorName(err) });
return;
};
 
const album: Db.Album.Index = a: {
const gop = db.albums.getOrPut(gpa, .{
.title = metadata.album,
.year = metadata.year,
.track_count = metadata.track_count,
.disc_number = metadata.disc_number,
.disc_count = metadata.disc_count,
.artist = Db.Album.ArtistIndex.init(metadata.album_artist, db, metadata.compilation),
}) catch |err| {
std.log.err("unable to add '{s}' to database: {s}", .{ basename_z, @errorName(err) });
return;
};
break :a @enumFromInt(gop.index);
};
 
const file_ptr = db.file(file_index);
const basename = file_ptr.basename;
file_ptr.* = .{
.directory = file_ptr.directory,
.basename = basename,
.title = metadata.title.unwrap() orelse basename,
.artist = metadata.artist,
.album = album,
.track_number = metadata.track_number,
.composer = metadata.composer,
.performer = metadata.performer,
.duration = .{
.sample_count = analysis.duration.sample_count,
.sample_rate = analysis.duration.sample_rate,
},
.true_peak = @floatCast(analysis.true_peak),
.integrated_loudness = @floatCast(analysis.integrated_loudness),
};
}
};
 
const FileMetaData = struct {
/// Zero sample rate indicates absence of duration metadata.
@@ -578,7 +659,7 @@ const FileMetaData = struct {
compilation: bool,
};
 
fn fileMetaData(gpa: Allocator, db: *Db, file: *player.File) !FileMetaData {
fn fileMetaData(gpa: Allocator, db: *Db, file: *player.File) Allocator.Error!FileMetaData {
const estimated_duration = file.estimatedDuration();
 
var result: FileMetaData = .{
 
shared/Db.zig added: 198, removed: 116, total 82
@@ -194,6 +194,8 @@ pub const File = extern struct {
composer: OptionalString,
performer: OptionalString,
duration: Duration,
true_peak: f32,
integrated_loudness: f32,
 
pub const Index = enum(u32) {
_,