diff --git a/player/db/update.c b/player/db/update.c
new file mode 100644
index 0000000000000000000000000000000000000000..0a2dec9e7d5c5b13989ec73707775184fe1b3912
--- /dev/null
+++ b/player/db/update.c
@@ -0,0 +1,134 @@
+#include "update.h"
+
+#include <dirent.h>
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#define SQL_LAST_UPDATE "SELECT last_update FROM misc;"
+
+static void
+serror(sqlite3 *db, const char *msg)
+{
+    fprintf(stderr, "%s: %s\n", msg, sqlite3_errmsg(db));
+}
+
+static inline void *
+mallocf(size_t n)
+{
+    void *res = malloc(n);
+    if (!res)
+        fprintf(stderr, "Failed to allocate %ld bytes.\n", n);
+    return res;
+}
+
+static int
+directories_and_files(const struct dirent *entry)
+{
+    return entry->d_type == DT_REG || entry->d_type == DT_DIR;
+}
+
+static int
+update_file(sqlite3 *db, const char *filename, int last_update)
+{
+    // TODO
+    return 0;
+}
+
+static int
+update_directory(sqlite3 *db, const char *directory, int last_update)
+{
+    int status_code = -1;
+    char **queue;
+    size_t queue_max = 128;
+    size_t queue_len = 0;
+
+    queue = mallocf(queue_max * sizeof(char *));
+    if (!queue)
+        goto error;
+
+    queue[0] = strdup(directory);
+    queue_len++;
+
+    while (queue_len) {
+        struct dirent **children;
+        int n;
+        char *dir = queue[--queue_len];
+
+        n = scandir(dir, &children, directories_and_files, alphasort);
+        if (n < 0)
+            fprintf(stderr, "Failed to open '%s': %s.\n", dir, strerror(errno));
+
+        for (int i = 0; i < n; i++) {
+            size_t dirlen = strlen(dir);
+            char *child = mallocf(dirlen + 258);  // child length (256) + null byte + slash
+            if (!child)
+                goto error;
+
+            strcpy(child, dir);
+            child[dirlen] = '/';
+            strcpy(child + dirlen + 1, children[i]->d_name);
+
+            if (children[i]->d_type == DT_REG) {
+                update_file(db, child, last_update);
+                free(child);
+            } else {
+                queue[queue_len++] = child;
+            }
+        }
+    }
+
+    status_code = 0;
+error:
+    for (size_t i = 0; i < queue_len; i++)
+        free(queue[i]);
+    free(queue);
+    return status_code;
+}
+
+int
+lektor_db_update(sqlite3 *db, const char *directory)
+{
+    if (sqlite3_exec(db, "BEGIN TRANSACTION;", 0, 0, 0) != SQLITE_OK) {
+        serror(db, "Failed to start transaction");
+        return -1;
+    }
+
+    sqlite3_stmt *stmt = 0;
+    int last_update = 0;
+
+    if (sqlite3_prepare_v2(db, SQL_LAST_UPDATE, -1, &stmt, 0) != SQLITE_OK) {
+        serror(db, "Failed to get last update time");
+        goto error;
+    }
+    switch (sqlite3_step(stmt)) {
+    case SQLITE_ROW:
+        last_update = sqlite3_column_int(stmt, 0);
+        break;
+    case SQLITE_DONE:
+        fprintf(stderr, "Failed to get last update time: table misc is empty");
+        goto error;
+    default:
+        serror(db, "Failed to get last update time");
+        goto error;
+    }
+
+    if (update_directory(db, directory, last_update) < 0)
+        goto error;
+
+    if (sqlite3_exec(db, "COMMIT;", 0, 0, 0) != SQLITE_OK) {
+        serror(db, "Failed to commit transaction");
+        goto error;
+    }
+
+    sqlite3_finalize(stmt);
+    return 0;
+
+error:
+
+    sqlite3_finalize(stmt);
+    if (sqlite3_exec(db, "ROLLBACK;", 0, 0, 0) != SQLITE_OK)
+        serror(db, "Failed to rollback transaction, database is corrupted");
+    return -1;
+}
diff --git a/player/db/update.h b/player/db/update.h
new file mode 100644
index 0000000000000000000000000000000000000000..0ae98194676db61ca307a27669aa7b987e61c3d0
--- /dev/null
+++ b/player/db/update.h
@@ -0,0 +1,6 @@
+#pragma once
+
+#include <sqlite3.h>
+
+int
+lektor_db_update(struct sqlite3 *db, const char *directory);