Skip to content

Support reading the AlphaTheta OneLibrary format - #196

Open
v1vendi wants to merge 6 commits into
xsco:mainfrom
v1vendi:onelibrary
Open

Support reading the AlphaTheta OneLibrary format#196
v1vendi wants to merge 6 commits into
xsco:mainfrom
v1vendi:onelibrary

Conversation

@v1vendi

@v1vendi v1vendi commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Partially solves #177
The PR is huge and made with help of AI, but what is not nowadays...
At least I did multiple rounds of guided review and cleanup.
Tested on MacOS with a real USB exported from Rekordbox.
I already prepared and tested integration to MIXXX too.
Writing also shouldn't be hard, but seems reasonable to start with readonly mode.

No need for SQLCipher dependency mentioned in #191
The library is one SQLite database encrypted with SQLCipher 4. Its page format is implemented in util/crypto, so neither SQLCipher nor OpenSSL becomes a dependency: AES-256-CBC, SHA-512, HMAC and PBKDF2 are written from their specifications and covered by test vectors from FIPS 197, FIPS 180-4, RFC 4231 and RFC 6070.

Rekordbox leaves most of a fresh export in the write-ahead log rather than the database file, so a reader that ignores the log reports a nearly empty library with no error at all. The log is folded in before SQLite ever sees the bytes.
The passphrase is the same on every installation and depends on neither licence nor machine, so any player can read any device. It can be overridden, should a future release of rekordbox change it.

Scope

Support is read-only: track metadata, playlists and crates can be read, and everything that would change a database throws unsupported_operation. The format keeps a single tree serving as both playlists and crates, so playlists_and_crates_are_distinct is false.

Beat grids, waveforms, hot cues and loops are not in the database at all — rekordbox leaves them in the ANLZ files beside the music, and exports an empty cue table — so those accessors return nothing rather than throwing. For a caller that reads ANLZ files itself, onelibrary::library gives the analysis path recorded for a track, along with the key notation as written (which may be Camelot, and so cannot be represented by track::key()) and the track colour.

Tests

Unit tests cover the crypto primitives against published test vectors, the content and playlist tables, and the database as a whole, reading fixtures that are built as plain SQLite and encrypted independently by the test suite, so a round trip checks the implementation against the format rather than against itself.

OneLibrary, also documented as Device Library Plus, is the successor to
the DeviceSQL export.pdb library that rekordbox writes to USB media.  A
device usually carries both, and a player that understands OneLibrary
prefers it.

The library is a single SQLite database encrypted with SQLCipher 4.  Its
page format is implemented in util/crypto, so neither SQLCipher nor
OpenSSL is needed to read one: AES-256-CBC, SHA-512, HMAC and PBKDF2 are
written from their specifications, and the write-ahead log is folded in
before SQLite sees the file, as rekordbox leaves most of a fresh export
in the log.

Support is read-only.  Track metadata, playlists and crates can be read;
everything that would change a database throws unsupported_operation.
Beat grids, waveforms, hot cues and loops are not in the database at
all, as rekordbox leaves them in the ANLZ files beside the music, so
onelibrary::library gives the path recorded for a track alongside the
key notation and track colour that the format-agnostic interface has
nowhere to put.

Reading a database needs SQLite 3.36 or newer, built without
SQLITE_OMIT_DESERIALIZE.
@mr-smidge

Copy link
Copy Markdown
Contributor

Hi @v1vendi , thank you very much for this PR. I'll take a look through, but I want to set expectations: as this is the first code for a new format in the library, it may take some time and back-and-forth to get this ready for merging.

I don't have any problem with the use of AI to assist with coding - in fact, I should probably add some statements along these lines to the contributing guidelines. One thing I will say is that I will treat this PR as though Claude was not there: it'll still be assessed to the same levels of quality as any other.

Anyway, this looks like a great start - first-pass review incoming soon...

@acrilique

Copy link
Copy Markdown

Hey! I'm interested in this topic so I might take a look at the proposed changes.

I have a couple of questions:

  • Is it better to reimplement the library's page format rather than use sqlcipher as a dependency? Won't there be any performance losses vs just using sqlcipher?
  • Given that both OneLibrary and pdb use the ANLZ files, would it make sense to coordinate the efforts in some way? Like, if you end up implementing read/write of ANLZ here, that would then overlap with what we have in rekordcrate and so if rekordcrate ever gets added as a dependency we would then have duplicate code.

@v1vendi

v1vendi commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Hey @acrilique
Regarding SQLCipher: it definitely wins performance-wise, mainly because my version of AES is the slow textbook version, and this should definitely be solved, I'll update the PR with some proposed optimisations.
In my opinion, we don't need to change the database dependency when we can solve it with a small bunch of crypto helpers, but I am open for changes.

Regarding ANLZ files, tbh I didn't think too far into the future, and now my goal is to make MIXXX import modern Pioneer USBs, and after that I am ready to start investigating export :)

Reading an encrypted database was dominated by software AES and by
copying, so several layers of that are removed:

* AES uses the processor's own AES instructions where it has them
  (x86 AES-NI, the ARMv8 cryptographic extension), decided at run time
  and confined to `aes_hardware.cpp`, which the build compiles with the
  intrinsics enabled only where they compile at all.  Decryption runs
  eight independent blocks at a time to keep the pipeline full.
* The software fallback moves to fused round tables and to the
  equivalent inverse cipher, so both directions have the same shape.
* SHA-512 keeps a sixteen-word wrapping schedule and unrolls its rounds
  by rotating variable names instead of shifting values.
* HMAC keys absorb both padded blocks once, which halves key derivation
  over its 256,000 iterations and saves two compressions per page.
* Page tags are computed over the ciphertext, IV and page number in
  place, rather than gathering each page into a scratch buffer.
* The write-ahead log is read before the database, so a page the log
  replaces or truncates away is never decrypted at all.
* Page decryption is spread over the available processors, pages being
  independent of one another.

Tests gain the NIST SP 800-38A chaining vectors and run every cipher
test over both implementations, so the tables are still exercised on a
machine that would otherwise only ever use its AES instructions.
@acrilique

Copy link
Copy Markdown

Hi @v1vendi

I'd be cool if you managed to make mixxx import modern pioneer usbs as a first step. I'm mainly focusing on export since day one, and I'm trying to think of the exported USB as a whole (i.e. onelibrary + pdb + settings files + anlz). In my mind, a library that allowed (but not necessarily enforced) you to generate it all with ease would be the ideal as it would reach the highest point of compatibility with all hardware, old and new.

However when it comes to reading, I've done less investigation and brainstorming for a design.

I suppose it could be symmetrical: if on the write-side you're able to either add a track/playlist to pdb, to onelibrary or to both at once, then on the read side you could also have the option to read tracks from pdb, from onelibrary, or from both (with something that deduplicated them by e.g. the file path).

Sorry if this was a bit too much thinking out loud.

I'm looking (not very actively tho) for people to help on ideas for the library design, testing exports with real hardware, reviewing code, etc in case you're ever interested.

GCC 12.3 on x86-64 rejects `resolve`, where the path is trimmed a level
at a time by assigning a piece of a string back to itself.  Inlining
turns that into a memcpy whose bounds it cannot establish, and it warns
of an overlap of nine quintillion bytes, which -Werror then makes fatal.

Track the end of the prefix as an index instead and build the string
once, at the end.  The construct the warning fires on is gone, and no
path resolves any differently: the two agree on every string up to
length six over `/`, `\` and a letter, and on the absolute, relative,
Windows and UNC cases besides.
@v1vendi

v1vendi commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

I pushed a significantly more fast implementation of crypto algorithms, which is almost on par with OpenSSL - but still does not require ~4mb of OpenSSL as a dependency.
(Now struggling a bit with build on different platforms, but will eventually figure it out)

Two things went wrong with `-DSYSTEM_SQLITE=OFF`, which is how the
macOS builds are configured.

A test that talks to SQLite directly failed to link.  The bundled
amalgamation is compiled into the library, whose symbols are hidden, so
there was nothing for the test to link against; the tests that use
SQLite now compile it themselves, as they already do for the internal
sources they reach into.

That then exposed the second: the bundled amalgamation is 3.33, and
reading a decrypted database needs `sqlite3_deserialize` from 3.36.
The library correctly reports the format unsupported there, so the
database test is registered only where SQLite can do the work, and
CMake says so when it is left out.
@v1vendi

v1vendi commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

So, as I found out, Onelibrary needs sqlite3_deserialize which is from SQLite 3.36, but the bundled version is 3.33.
I decided to update to latest SQLite version 3.53.4, but the overall diff continues growing.
Moved SQLite update to a separate PR

Reading a decrypted database goes through `sqlite3_deserialize`, which
arrived in SQLite 3.36, so the test of it is registered only where that
much is available.  It asked about the system installation alone, which
left the bundled copy out of the reckoning altogether.

Read the bundled version out of the amalgamation instead, and ask the
same question of both.  Nothing changes while the bundled copy is 3.33,
which cannot do the work; when it is newer the test follows.
Each of them talks to SQLite directly, and a test cannot borrow it from
the library, whose symbols are hidden.  With `-DSYSTEM_SQLITE=OFF` that
means every one of them compiles the amalgamation into itself, some
fifteen seconds apiece, on top of the copy the library already builds.

They are commented out rather than deleted, and their sources are left
where they are, so uncommenting the block runs them again.

@mr-smidge mr-smidge left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is a great start, and I've tried to be comprehensive in my review.

I think there are three main themes that we should think about:

  1. The choice of SQLCipher vs. an explicit crypto implementation in libdjinterop. Although the minimal code has some advantages, I keep thinking that libdjinterop is not a crypto library and shouldn't try to be. Yet at the same time, nobody has tried to get SQLCipher in yet so if there are any challenges we don't yet know them. This can be discussed more on #191.
  2. I think there needs to be cleaner separation between the high-level and low-level API for OneLibrary.
  3. The low-level API may presumably undergo version changes like any other format (I don't have enough experience with AlphaTheta equipment/formats to know how much this has already happened since the format was first released), and I think it would be sensible to organise low-level API code such that any breaking changes to the underlying format can be easily handled.

Many thanks for your efforts here - let me know what you think.

/// memory, so a caller that wants both the unified interface and the extras
/// below should load a `library` and take `db()` from it rather than also
/// calling `load_database`.
class DJINTEROP_PUBLIC library

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The functions above (database_exists(), load_database()) would be considered part of libdjinterop's high-level API, whereas this library class seems to contain things that are specific to the OneLibrary format and hence part of a low-level API. See GUIDE.md for more info.

I think these should be split into separate public headers.

Does OneLibrary have any kind of versioning scheme to go with it? Has it undergone any significant version changes since it was first released that might give us a hint as to how new versions of the schema are released? (This is the reason that the engine implementations have engine/v2, engine/v3, etc.)

Comment thread example/README.md
| `engine_library_v2_low_level` | Uses the low-level Engine v2 API to work with tables directly. |
| `onelibrary` | Prints the tracks and playlists of an AlphaTheta OneLibrary device. |

Each application can be minimally compiled in isolation with an invocation

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a realistic use case, compiling the examples in isolation?

If the user has gone to the trouble of cloning this repo, is there some advantage offered by compiling in isolation rather than the CMake targets that are already there?

/// \return Returns the path, or no value if the track is not there or
/// carries no analysis data.
[[nodiscard]] std::optional<std::string> analysis_path(
int64_t track_id) const;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this and the below member functions, why are these part of the library class, as opposed to exposing the low-level concepts more directly?

Being more specific, the SQL to implement these appears to be fetching data from the content table... so perhaps a low-level API for OneLibrary should expose the content table as a first-class citizen instead? This would match how the Engine low-level API works in terms of exposing rows and columns from the key tables, and make a clean separation between the low-level and high-level API logic.

@@ -0,0 +1,124 @@
/*

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How come this isn't a public header?

/// may point at a row that is not there.
struct content_row
{
int64_t id = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does zero have a special meaning?

If so, consider using a constant to make the meaning obvious, e.g. https://github.com/xsco/libdjinterop/blob/main/include/djinterop/engine/v2/track_table.hpp#L57

#include <string>
#include <vector>

#include <sqlite3.h>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is currently no test code that needs to talk to SQLite directly, let alone using the C API. So I think this #include being here is a code smell.

Engine unashamedly offers a function djinterop::engine::create_database_from_scripts(), which is used by test code. Perhaps OneLibrary tests should do something similar?

/// `album.image_id`, and gives `playlist_content` and `property` primary keys
/// that an export does not carry. What a device holds is what is written
/// here.
inline const std::vector<std::string>& onelibrary_schema_statements()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does an 'empty' database have any entries in the property table, e.g. to indicate the version?

/// here.
inline const std::vector<std::string>& onelibrary_schema_statements()
{
static const std::vector<std::string> statements{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it might be better to follow the existing pattern and put database schema in <libdjinterop>/testdata/ref/onelibrary rather than embed in C++ sources. Combine with an equivalent to Engine's create_database_from_scripts() to get their data.

Comment thread .gitignore
/cmake_build*
/cmake-build*

/build

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this for?

Comment thread CMakeLists.txt
Comment on lines +584 to +585
# The OneLibrary tests are commented out below. Each of them talks to
# SQLite directly, and a test cannot borrow SQLite from the library: its

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment in database_test.cpp - I think it might be best if the tests only use the public API of libdjinterop and don't talk to SQLite directly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants