diff --git a/CMakeLists.txt b/CMakeLists.txt index b9411f77..a4ade4cd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,6 +82,11 @@ add_library( include/djinterop/engine/v3/track_table.hpp include/djinterop/exceptions.hpp include/djinterop/musical_key.hpp + include/djinterop/onelibrary/onelibrary.hpp + include/djinterop/onelibrary/v1/content_table.hpp + include/djinterop/onelibrary/v1/library.hpp + include/djinterop/onelibrary/v1/playlist_table.hpp + include/djinterop/onelibrary/v1/property_table.hpp include/djinterop/pad_color.hpp include/djinterop/performance_data.hpp include/djinterop/playlist.hpp @@ -203,6 +208,22 @@ add_library( src/djinterop/impl/playlist_impl.hpp src/djinterop/impl/track_impl.cpp src/djinterop/impl/track_impl.hpp + src/djinterop/onelibrary/onelibrary.cpp + src/djinterop/onelibrary/onelibrary_context.hpp + src/djinterop/onelibrary/v1/content_table.cpp + src/djinterop/onelibrary/v1/crate_impl.cpp + src/djinterop/onelibrary/v1/crate_impl.hpp + src/djinterop/onelibrary/v1/database_impl.cpp + src/djinterop/onelibrary/v1/database_impl.hpp + src/djinterop/onelibrary/v1/library.cpp + src/djinterop/onelibrary/v1/playlist_impl.cpp + src/djinterop/onelibrary/v1/playlist_impl.hpp + src/djinterop/onelibrary/v1/playlist_table.cpp + src/djinterop/onelibrary/v1/property_table.cpp + src/djinterop/onelibrary/v1/track_conversion.cpp + src/djinterop/onelibrary/v1/track_conversion.hpp + src/djinterop/onelibrary/v1/track_impl.cpp + src/djinterop/onelibrary/v1/track_impl.hpp src/djinterop/playlist.cpp src/djinterop/track.cpp src/djinterop/util/chrono.cpp @@ -211,9 +232,19 @@ add_library( src/djinterop/util/filesystem.hpp src/djinterop/util/random.cpp src/djinterop/util/random.hpp + src/djinterop/util/sqlcipher.hpp + src/djinterop/util/sqlite_query.hpp src/djinterop/util/sqlite_transaction.hpp ) +# Encrypted OneLibrary databases are opened with SQLCipher, where the build has +# it. Otherwise, opening one fails with an explanation. +if(EXPERIMENTAL_ENABLE_SQLCIPHER) + target_sources(DjInterop PRIVATE src/djinterop/util/sqlcipher.cpp) +else() + target_sources(DjInterop PRIVATE src/djinterop/util/sqlcipher_unsupported.cpp) +endif() + set_target_properties(DjInterop PROPERTIES OUTPUT_NAME "djinterop" VERSION ${PROJECT_VERSION} @@ -238,7 +269,7 @@ target_include_directories( DjInterop PUBLIC $ $ - $) + $) # Always rely on system installation of zlib. set(ZLIB_MIN_VERSION 1.2.8) @@ -318,6 +349,14 @@ endif() set_target_properties(DjInterop PROPERTIES C_VISIBILITY_PRESET hidden) set_target_properties(DjInterop PROPERTIES CXX_VISIBILITY_PRESET hidden) +# A static DjInterop does not carry the SQLCipher library inside it, so that +# library is installed beside it for consumers to link as well. +if(EXPERIMENTAL_ENABLE_SQLCIPHER) + install(TARGETS sqlcipher + EXPORT DjInteropTargets + ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}") +endif() + install(TARGETS DjInterop EXPORT DjInteropTargets ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" @@ -370,6 +409,17 @@ install(FILES include/djinterop/engine/v3/track_data_blob.hpp include/djinterop/engine/v3/track_table.hpp DESTINATION "${DJINTEROP_INSTALL_INCLUDEDIR}/engine/v3") +install(FILES + include/djinterop/onelibrary/onelibrary.hpp + DESTINATION "${DJINTEROP_INSTALL_INCLUDEDIR}/onelibrary") + +install( + FILES + include/djinterop/onelibrary/v1/content_table.hpp + include/djinterop/onelibrary/v1/library.hpp + include/djinterop/onelibrary/v1/playlist_table.hpp + include/djinterop/onelibrary/v1/property_table.hpp + DESTINATION "${DJINTEROP_INSTALL_INCLUDEDIR}/onelibrary/v1") if (UNIX) @@ -420,6 +470,7 @@ if (BUILD_EXAMPLES) add_djinterop_example(engine_prime) add_djinterop_example(engine_library_v2_low_level) + add_djinterop_example(onelibrary) endif() # Unit tests. @@ -465,6 +516,18 @@ if (Boost_FOUND AND Boost_filesystem_FOUND AND Boost_system_FOUND) add_djinterop_test(engine/v3/ performance_data_table_test) add_djinterop_test(engine/v3/ track_table_test) + # The OneLibrary tests create encrypted databases, which only a build with + # SQLCipher can do. + if(EXPERIMENTAL_ENABLE_SQLCIPHER) + add_djinterop_test(onelibrary/ content_table_test) + add_djinterop_test(onelibrary/ database_test) + add_djinterop_test(onelibrary/ playlist_table_test) + else() + message( + STATUS + "OneLibrary tests not available, as reading the format needs " + "EXPERIMENTAL_ENABLE_SQLCIPHER") + endif() else() message(STATUS "Unit tests not available, as the Boost.Filesystem and Boost.System " diff --git a/DjInteropConfig.cmake.in b/DjInteropConfig.cmake.in index 22d88cfb..7690bac8 100644 --- a/DjInteropConfig.cmake.in +++ b/DjInteropConfig.cmake.in @@ -14,6 +14,9 @@ endif() if(DJINTEROP_SYSTEM_SQLITE) find_dependency(SQLite3) endif() +if(DJINTEROP_EXPERIMENTAL_ENABLE_SQLCIPHER) + find_dependency(OpenSSL) +endif() include("${CMAKE_CURRENT_LIST_DIR}/DjInteropTargets.cmake") diff --git a/GUIDE.md b/GUIDE.md index 875ccc77..fc371ae1 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -73,9 +73,53 @@ As such, in order to create a new library or load an existing library with the intention of operating on it using the high-level API, it is always necessary to start with format-specific functions to do so: -| Library Type | Include Path | -|--------------|------------------------------------------| -| Engine | `#include ` | +| Library Type | Include Path | +|--------------|--------------------------------------------------| +| Engine | `#include ` | +| OneLibrary | `#include ` | + +OneLibrary +---------- + +The AlphaTheta OneLibrary format, also documented as Device Library Plus, is +the successor to the DeviceSQL `export.pdb` library that rekordbox wrote to USB +media. A database is loaded by way of `onelibrary::load_database()`, given +either the root directory of a device or the database file itself. A number of +aspects of the format are worth noting: + +* Support is currently read-only, and everything that would change a database + throws `djinterop::unsupported_operation`. +* Beat grids, waveforms, hot cues and loops are not held in the database. + rekordbox leaves them in the ANLZ files that `content.analysisDataFilePath` + points at, and does not populate the `cue` table on export. Those accessors + therefore return nothing rather than throwing. A caller that reads ANLZ + files itself reaches that path through the low-level API, described below. +* The low-level API also reaches the two other things a device carries that + the format-agnostic interface has nowhere to put: the musical key in the + notation rekordbox wrote, which may be Camelot and which `track::key()` + cannot represent, and the colour the DJ marked a track with, numbered as + `export.pdb` numbers them. +* The format has a single tree that serves as both playlists and crates, so + `playlists_and_crates_are_distinct` is false and the two views show the same + rows. +* The database is encrypted with SQLCipher. libdjinterop does not supply the + passphrase, so an application has to pass it to `load_database()`. +* Reading an encrypted database needs libdjinterop built with + `-DEXPERIMENTAL_ENABLE_SQLCIPHER=ON -DSYSTEM_SQLITE=OFF`, and OpenSSL. + Without it, `load_database()` throws `djinterop::unsupported_database`. +* rekordbox writes the database in write-ahead-logged mode, and SQLite cannot + read one of those without writing beside it, so reading a device creates + `-shm` and `-wal` files next to `exportLibrary.db` if they are missing. The + device therefore has to be writable. + +### OneLibrary low-level API + +The low-level API is in ``, and exposes +the tables as the device holds them, translating no further than resolving a +lookup reference to the text behind it. A device is loaded as a +`onelibrary::v1::library`. + +The `v1` is the schema those tables describe. Stable API/ABI diff --git a/README.md b/README.md index 4951cd10..e275b997 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ State of Support ================ The library is currently in development, and not all features are implemented -yet. It currently supports only the Engine Library format. +yet. It supports the Engine Library format, and can read the AlphaTheta +OneLibrary format. What is supported: @@ -27,11 +28,22 @@ The library supports the following firmware and application versions: SC6000/M) may work, but this is currently untested. * Engine DJ Desktop (aka Engine Prime) from 1.0.1 to 4.3.0. +The library also reads the AlphaTheta OneLibrary format, which rekordbox 7 +writes to USB media as `PIONEER/rekordbox/exportLibrary.db`, and which players +from the CDJ-3000X, XDJ-AZ, OPUS-QUAD and OMNIS-DUO onwards read, as does the +CDJ-3000 from firmware 3.15. Track metadata, playlists and crates can be read. +Writing is not supported yet, and beat grids, waveforms, hot cues and loops are +not held in the database at all, as rekordbox leaves them in the ANLZ files +beside it. The format is also documented under the name Device Library Plus. +The database is encrypted with SQLCipher, so reading it needs the library built +with `-DEXPERIMENTAL_ENABLE_SQLCIPHER=ON -DSYSTEM_SQLITE=OFF`, and OpenSSL. + What is not supported (yet): * Album art * Play history -* DJ record libraries in formats other than Engine Prime +* Writing OneLibrary databases +* DJ record libraries in formats other than Engine Prime and OneLibrary How Do I Use It? ================ diff --git a/example/README.md b/example/README.md index 5f247686..dd4e6146 100644 --- a/example/README.md +++ b/example/README.md @@ -4,9 +4,31 @@ Overview This directory contains small example applications that illustrate the use of `libdjinterop`. -This application can be minimally compiled in isolation with an invocation -similar to the below (adjust for your favourite compiler as appropriate): +| Example | What it does | +|-------------------------------|---------------------------------------------------------------------| +| `engine_prime` | Writes a track, a crate and a playlist to an Engine Prime library. | +| `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. | + +They are built by the project itself, as `example_engine_prime` and so on, +when it is configured with `-DBUILD_EXAMPLES=ON`. + +To build one of them against an installed `libdjinterop` instead, as a +starting point for a program of your own: + +```shell +g++ -std=c++20 `pkg-config --cflags djinterop` onelibrary.cpp `pkg-config --libs djinterop` -o onelibrary +``` + +The library needs a C++20 compiler, and its headers refuse to compile under +any older standard. + +`onelibrary` takes the device to read as its argument, either the root +directory of a device or the `exportLibrary.db` file itself, and the +passphrase with which the database is encrypted. libdjinterop does not supply +the passphrase, so it has to be given either as the second argument or in the +`ONELIBRARY_PASSPHRASE` environment variable: ```shell -g++ -std=c++17 `pkg-config --cflags djinterop` engine_prime.cpp `pkg-config --libs djinterop` -``` \ No newline at end of file +ONELIBRARY_PASSPHRASE=... ./onelibrary /Volumes/MYUSB +``` diff --git a/example/onelibrary.cpp b/example/onelibrary.cpp new file mode 100644 index 00000000..3a30bd8d --- /dev/null +++ b/example/onelibrary.cpp @@ -0,0 +1,117 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +// Print the contents of an AlphaTheta OneLibrary device. +// +// onelibrary /Volumes/MYUSB [passphrase] +// +// The first argument is the root of the device -- the directory holding +// `PIONEER` -- or the `exportLibrary.db` file itself. The second is the +// passphrase with which the database is encrypted, which libdjinterop does +// not supply; without it, the `ONELIBRARY_PASSPHRASE` environment variable is +// read instead. + +#include +#include +#include + +#include + +namespace +{ +void print_playlist(const djinterop::playlist& pl, int depth) +{ + const std::string indent(static_cast(depth) * 2, ' '); + std::cout << indent << "- " << pl.name() << " (" << pl.tracks().size() + << " tracks)\n"; + + for (auto&& child : pl.children()) + print_playlist(child, depth + 1); +} + +} // anonymous namespace + +int main(int argc, char** argv) +{ + if (argc < 2) + { + std::cerr << "usage: onelibrary [passphrase]\n"; + return 2; + } + + const std::string device = argv[1]; + const char* passphrase = + argc > 2 ? argv[2] : std::getenv("ONELIBRARY_PASSPHRASE"); + if (passphrase == nullptr) + { + std::cerr << "No passphrase given, either as an argument or in " + "ONELIBRARY_PASSPHRASE\n"; + return 2; + } + + if (!djinterop::onelibrary::database_exists(device)) + { + std::cerr << "No OneLibrary database found in " << device << "\n"; + return 1; + } + + try + { + auto db = djinterop::onelibrary::load_database(device, passphrase); + + std::cout << "Format: " << db.version_name() << "\n" + << "Library: " << db.uuid() << "\n\n"; + + std::cout << "Tracks\n------\n"; + for (auto&& track : db.tracks()) + { + // A snapshot reads the whole track at once, where each accessor + // would query the database again. + const auto snapshot = track.snapshot(); + std::cout << track.id() << ". " + << snapshot.title.value_or("(untitled)") << " - " + << snapshot.artist.value_or("(unknown artist)"); + + if (snapshot.bpm) + std::cout << " [" << *snapshot.bpm << " BPM]"; + + if (snapshot.key) + std::cout << " [" << *snapshot.key << "]"; + + std::cout << "\n " << snapshot.relative_path.value_or("") + << "\n"; + } + + std::cout << "\nPlaylists\n---------\n"; + for (auto&& pl : db.root_playlists()) + print_playlist(pl, 0); + } + catch (const djinterop::unsupported_database& e) + { + // The most likely cause is a passphrase that does not open the + // database, which happens if rekordbox has changed it. + std::cerr << "Could not read the library: " << e.what() << "\n"; + return 1; + } + catch (const djinterop::database_inconsistency& e) + { + std::cerr << "The library is damaged: " << e.what() << "\n"; + return 1; + } + + return 0; +} diff --git a/ext/sqlcipher/CMakeLists.txt b/ext/sqlcipher/CMakeLists.txt index 1bd95336..0aca02aa 100644 --- a/ext/sqlcipher/CMakeLists.txt +++ b/ext/sqlcipher/CMakeLists.txt @@ -27,7 +27,7 @@ target_compile_definitions( target_include_directories( sqlcipher PUBLIC - ${CMAKE_CURRENT_SOURCE_DIR}) + $) # SQLCipher needs OpenSSL for AES-256-CBC, PBKDF2-HMAC-SHA512 find_package(OpenSSL REQUIRED) @@ -46,3 +46,8 @@ endif() # Must be position-independent to link into a shared library. set_target_properties(sqlcipher PROPERTIES POSITION_INDEPENDENT_CODE ON) + +# Keep SQLite's symbols inside a shared libdjinterop. An application that +# links its own SQLite would otherwise interpose its copy, which cannot +# decrypt, on the calls libdjinterop makes. +set_target_properties(sqlcipher PROPERTIES C_VISIBILITY_PRESET hidden) diff --git a/include/djinterop/djinterop.hpp b/include/djinterop/djinterop.hpp index f0cfe828..962aab65 100644 --- a/include/djinterop/djinterop.hpp +++ b/include/djinterop/djinterop.hpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include diff --git a/include/djinterop/onelibrary/onelibrary.hpp b/include/djinterop/onelibrary/onelibrary.hpp new file mode 100644 index 00000000..90627a80 --- /dev/null +++ b/include/djinterop/onelibrary/onelibrary.hpp @@ -0,0 +1,87 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#pragma once + +#include + +#include +#include + +/// Support for the AlphaTheta OneLibrary device format, also documented as +/// Device Library Plus. +/// +/// OneLibrary succeeds the DeviceSQL `export.pdb` library that rekordbox wrote +/// to USB media; a device usually carries both, and a player that understands +/// OneLibrary prefers it. The library is one SQLite database encrypted with +/// SQLCipher 4, so reading one needs the passphrase with which it is +/// encrypted, which libdjinterop does not supply, and libdjinterop built with +/// `EXPERIMENTAL_ENABLE_SQLCIPHER`; without it, loading throws +/// `djinterop::unsupported_database`. +/// +/// Support here is currently **read-only**. Everything that changes a +/// database throws `djinterop::unsupported_operation`. +/// +/// This header is the high-level API, which presents a device through the +/// format-agnostic `database` interface. The low-level API, which exposes +/// the tables of the format as they are, is in `onelibrary/v1`. +namespace djinterop::onelibrary +{ +/// Location of the library database within a device, relative to its root. +constexpr const char* database_relative_path = + "PIONEER/rekordbox/exportLibrary.db"; + +/// Test whether a OneLibrary database exists in a given location. +/// +/// \param path Either the root directory of a device, or the database file. +/// \return Returns true if a database is present. +bool DJINTEROP_PUBLIC database_exists(const std::string& path); + +/// Load a OneLibrary database. +/// +/// \param path Either the root directory of a device, such as the mount point +/// of a USB drive, or the `exportLibrary.db` file itself. +/// \param passphrase Passphrase with which the database is encrypted. +/// \return Returns the loaded database. +/// \throws database_not_found If no database is present at the given path. +/// \throws unsupported_database If the passphrase does not open the database, +/// or this build of libdjinterop cannot read one. +/// \throws database_inconsistency If the database does not hold the tables +/// that a OneLibrary database is expected to, +/// or the single `property` row recording its +/// version. +database DJINTEROP_PUBLIC load_database( + const std::string& path, const std::string& passphrase); + +/// Create a new database from an SQL script, and load it. +/// +/// The directory indicated by `script_directory` is expected to contain a file +/// named `exportLibrary.db.sql`, holding one statement per line. It is used +/// to hydrate an encrypted database at `database_relative_path` within +/// `directory`, which is then loaded as `load_database` would load it. +/// +/// \param directory Root directory of the device to create. +/// \param script_directory Directory containing the script. +/// \param passphrase Passphrase with which to encrypt the database. +/// \return Returns the created database. +/// \throws unsupported_database If this build of libdjinterop cannot write an +/// encrypted database. +database DJINTEROP_PUBLIC create_database_from_scripts( + const std::string& directory, const std::string& script_directory, + const std::string& passphrase); + +} // namespace djinterop::onelibrary diff --git a/include/djinterop/onelibrary/v1/content_table.hpp b/include/djinterop/onelibrary/v1/content_table.hpp new file mode 100644 index 00000000..be2640ad --- /dev/null +++ b/include/djinterop/onelibrary/v1/content_table.hpp @@ -0,0 +1,183 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace djinterop::onelibrary +{ +struct onelibrary_context; + +namespace v1 +{ +/// Special value for id to indicate that a given row is not a row of the +/// database. Every row read from a device carries a real identifier, so this +/// is only ever the value a default-constructed row holds. +constexpr int64_t CONTENT_ROW_ID_NONE = 0; + +/// Special value for the `color_id` column that indicates that a track is not +/// marked with a colour. +constexpr int64_t COLOR_ID_NONE = 0; + +/// One row of the `content` table, with its lookup tables resolved. +/// +/// The schema declares no constraints: nothing is `NOT NULL` and no foreign +/// key is enforced, so every field is optional and a lookup reference may +/// point at a row that is not there. +struct content_row +{ + int64_t id = CONTENT_ROW_ID_NONE; + + std::optional title; + std::optional artist; + std::optional composer; + std::optional album; + std::optional genre; + + /// The record label, which djinterop calls the publisher. + std::optional label; + + /// The musical key in the notation rekordbox wrote, which follows the + /// setting it exported under and may be Camelot, such as `8A`. + std::optional key; + + std::optional comment; + + /// Tempo in hundredths of a beat per minute: 12400 is 124.00 BPM. + std::optional bpm_x100; + + /// Duration, which the database records in whole seconds. + /// + /// The published description of the format calls this column + /// milliseconds, which it is not: on a real export, a 320 kbps track of + /// 6,517,615 bytes carries a `length` of 161, and 6517615 * 8 / 320000 is + /// 163 seconds. No other reading fits. + std::optional length; + + std::optional track_number; + std::optional release_year; + + /// Rating as a number of whole stars, which is any integer from zero + /// (unrated) to five inclusive. + /// + /// Note that this differs from other parts of the rekordbox ecosystem, + /// which encode the same rating on a scale of 0 to 255 in steps of 51. + std::optional rating_stars; + + /// Path of the audio file, relative to the root of the device. + /// + /// The device is the drive, or the directory standing in for one, at whose + /// top level the `PIONEER` directory sits. The path uses `/` as its + /// separator and, despite being relative to the device root, always begins + /// with one, such as `/Contents/Artist/Album/Track.mp3`. + std::optional path; + + /// Size of the file, in bytes. + std::optional file_size; + + /// Bitrate of the file, in kilobits per second. + std::optional bitrate; + + /// Sampling rate of the file, in hertz. + std::optional sampling_rate; + + /// Path of the ANLZ analysis data, as the device records it. + /// + /// rekordbox keeps the beatgrid, cues, loops and waveforms in files beside + /// the music rather than in the database. This names the `.DAT` one; its + /// siblings differ only in extension, `.EXT` holding the colour waveform + /// and the cues beyond the first three, and `.2EX` the waveform a touch + /// display draws. + std::optional analysis_path; + + /// The colour the track is marked with, or `COLOR_ID_NONE` for none. + /// + /// The eight colours rekordbox offers, numbered as the `export.pdb` + /// library beside the database numbers them: one for pink, two red, three + /// orange, four yellow, five green, six aqua, seven blue, eight purple. + std::optional color_id; +}; + +/// Read access to the `content` table and the lookup tables it references. +/// +/// A whole row can be read at once, or any one column on its own, which +/// avoids the joins that resolving every lookup table costs. +class DJINTEROP_PUBLIC content_table +{ +public: + explicit content_table(std::shared_ptr context); + + /// Fetch one row by its identifier. + [[nodiscard]] std::optional get(int64_t id) const; + + /// Fetch the identifiers of every row, ordered. + [[nodiscard]] std::vector all_ids() const; + + /// Fetch the identifiers of rows whose path matches, ordered. + /// + /// Paths in the database are relative to the root of the device and begin + /// with a separator; a path given without one is matched as though it had + /// one. + [[nodiscard]] std::vector ids_by_path( + const std::string& path) const; + + /// Test whether a row exists. + [[nodiscard]] bool exists(int64_t id) const; + + /// Fetch one column of one row. + /// + /// Each returns no value if the row is not there or the column is unset. + /// Text that is set but empty counts as unset, which is how rekordbox + /// writes metadata a track does not carry. + /// @{ + [[nodiscard]] std::optional get_title(int64_t id) const; + [[nodiscard]] std::optional get_artist(int64_t id) const; + [[nodiscard]] std::optional get_composer(int64_t id) const; + [[nodiscard]] std::optional get_album(int64_t id) const; + [[nodiscard]] std::optional get_genre(int64_t id) const; + [[nodiscard]] std::optional get_label(int64_t id) const; + [[nodiscard]] std::optional get_key(int64_t id) const; + [[nodiscard]] std::optional get_comment(int64_t id) const; + [[nodiscard]] std::optional get_bpm_x100(int64_t id) const; + [[nodiscard]] std::optional get_length( + int64_t id) const; + [[nodiscard]] std::optional get_track_number(int64_t id) const; + [[nodiscard]] std::optional get_release_year(int64_t id) const; + [[nodiscard]] std::optional get_rating_stars(int64_t id) const; + [[nodiscard]] std::optional get_path(int64_t id) const; + [[nodiscard]] std::optional get_file_size(int64_t id) const; + [[nodiscard]] std::optional get_bitrate(int64_t id) const; + [[nodiscard]] std::optional get_sampling_rate(int64_t id) const; + [[nodiscard]] std::optional get_analysis_path( + int64_t id) const; + [[nodiscard]] std::optional get_color_id(int64_t id) const; + /// @} + +private: + std::shared_ptr context_; +}; + +} // namespace v1 +} // namespace djinterop::onelibrary diff --git a/include/djinterop/onelibrary/v1/library.hpp b/include/djinterop/onelibrary/v1/library.hpp new file mode 100644 index 00000000..c40d1502 --- /dev/null +++ b/include/djinterop/onelibrary/v1/library.hpp @@ -0,0 +1,93 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace djinterop::onelibrary::v1 +{ +/// A loaded OneLibrary device, through the low-level API. +/// +/// The tables expose the format as the device holds it, translating no +/// further than resolving a lookup reference to the text behind it. +/// +/// Loading derives the key that decrypts the device, which is deliberately +/// expensive; take `database()` from a library rather than also calling +/// `load_database`. +class DJINTEROP_PUBLIC library +{ +public: + /// Construct an instance of the class using a OneLibrary context. + /// + /// \param context OneLibrary context. + explicit library(std::shared_ptr context); + + /// Test whether a OneLibrary database exists in a given location. + /// + /// \param path Either the root directory of a device, or the database + /// file. + [[nodiscard]] static bool exists(const std::string& path); + + /// Load the database on a device, as `load_database` does. + /// + /// \param path Either the root directory of a device, or the database + /// file. + /// \param passphrase Passphrase with which the database is encrypted. + [[nodiscard]] static library load( + const std::string& path, + const std::string& passphrase); + + /// Create a new database from an SQL script, as + /// `create_database_from_scripts` does, and load it. + /// + /// \param directory Root directory of the device to create. + /// \param script_directory Directory containing `exportLibrary.db.sql`. + /// \param passphrase Passphrase with which to encrypt the database. + [[nodiscard]] static library create_from_scripts( + const std::string& directory, const std::string& script_directory, + const std::string& passphrase); + + /// The `content` table. + [[nodiscard]] content_table content() const; + + /// The `playlist` table, and the membership beside it. + [[nodiscard]] playlist_table playlist() const; + + /// The `property` table, which records the schema version. + [[nodiscard]] property_table property() const; + + /// The device, through the format-agnostic interface. + [[nodiscard]] djinterop::database database() const; + + /// Root directory of the device, to which every path is relative, and + /// not the directory the database file itself sits in. + [[nodiscard]] const std::string& directory() const; + +private: + std::shared_ptr context_; +}; + +} // namespace djinterop::onelibrary::v1 diff --git a/include/djinterop/onelibrary/v1/playlist_table.hpp b/include/djinterop/onelibrary/v1/playlist_table.hpp new file mode 100644 index 00000000..bd5d28d5 --- /dev/null +++ b/include/djinterop/onelibrary/v1/playlist_table.hpp @@ -0,0 +1,96 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include + +namespace djinterop::onelibrary +{ +struct onelibrary_context; + +namespace v1 +{ +/// Special value for id to indicate that a given row is not a row of the +/// database. Every row read from a device carries a real identifier, so this +/// is only ever the value a default-constructed row holds. +constexpr int64_t PLAYLIST_ROW_ID_NONE = 0; + +/// Special value for the `playlist_id_parent` column that indicates that a +/// playlist has no parent. rekordbox also leaves the column unset to mean the +/// same thing. +constexpr int64_t PARENT_ID_NONE = 0; + +/// One row of the `playlist` table. +struct playlist_row +{ + int64_t id = PLAYLIST_ROW_ID_NONE; + std::string name; + + /// The playlist this one sits under, or no value for a root. + std::optional parent_id; + + /// Position among siblings, counting from one. + std::optional sequence_number; +}; + +/// Read access to the `playlist` tree and the `playlist_content` membership. +class DJINTEROP_PUBLIC playlist_table +{ +public: + explicit playlist_table(std::shared_ptr context); + + [[nodiscard]] std::optional get(int64_t id) const; + + [[nodiscard]] bool exists(int64_t id) const; + + /// Identifiers of the playlists with no parent, in sibling order. + [[nodiscard]] std::vector root_ids() const; + + /// Identifiers of the children of a playlist, in sibling order. + [[nodiscard]] std::vector child_ids(int64_t id) const; + + /// Identifiers of every playlist beneath one, breadth first. + [[nodiscard]] std::vector descendant_ids(int64_t id) const; + + /// Find a root playlist by name. + [[nodiscard]] std::optional find_root( + const std::string& name) const; + + /// Find a child of a playlist by name. + [[nodiscard]] std::optional find_child( + int64_t parent_id, const std::string& name) const; + + /// Identifiers of the tracks in a playlist, in playlist order. + [[nodiscard]] std::vector track_ids(int64_t id) const; + + /// Identifiers of the playlists that hold a given track. + [[nodiscard]] std::vector playlists_containing( + int64_t track_id) const; + +private: + std::shared_ptr context_; +}; + +} // namespace v1 +} // namespace djinterop::onelibrary diff --git a/include/djinterop/onelibrary/v1/property_table.hpp b/include/djinterop/onelibrary/v1/property_table.hpp new file mode 100644 index 00000000..5239f5ad --- /dev/null +++ b/include/djinterop/onelibrary/v1/property_table.hpp @@ -0,0 +1,67 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#pragma once + +#include +#include +#include +#include + +#include + +namespace djinterop::onelibrary +{ +struct onelibrary_context; + +namespace v1 +{ +/// The `dbVersion` that this implementation was written against. +constexpr const char* supported_db_version = "1000"; + +/// The single row of the `property` table, which describes the device. +struct property_row +{ + std::optional device_name; + + /// Version of the schema, such as `1000`. + std::optional db_version; + + /// Number of tracks the device was exported with, which need not be the + /// number the `content` table now holds. + std::optional number_of_contents; + + std::optional created_date; +}; + +/// Read access to the `property` table. +class DJINTEROP_PUBLIC property_table +{ +public: + explicit property_table(std::shared_ptr context); + + /// The row, or no value if the table is empty. + [[nodiscard]] std::optional get() const; + + [[nodiscard]] std::optional get_db_version() const; + +private: + std::shared_ptr context_; +}; + +} // namespace v1 +} // namespace djinterop::onelibrary diff --git a/src/djinterop/onelibrary/onelibrary.cpp b/src/djinterop/onelibrary/onelibrary.cpp new file mode 100644 index 00000000..634b5937 --- /dev/null +++ b/src/djinterop/onelibrary/onelibrary.cpp @@ -0,0 +1,193 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "../util/sqlcipher.hpp" +#include "onelibrary_context.hpp" +#include "v1/database_impl.hpp" + +namespace djinterop::onelibrary +{ +namespace +{ +struct resolved_location +{ + /// Root directory of the device, to which track paths are relative. + std::string directory; + + std::string database_path; +}; + +/// Work out where the database is, given a device or the file itself. +resolved_location resolve(const std::string& path) +{ + if (std::filesystem::is_directory(path)) + return resolved_location{path, path + "/" + database_relative_path}; + + // A path that names the database directly implies its device root, which + // is three levels up: `/PIONEER/rekordbox/exportLibrary.db`. A + // relative path with nothing above it sits in the working directory, which + // is then the root of the device. + auto root = + std::filesystem::path{path}.parent_path().parent_path().parent_path(); + if (root.empty()) + root = "."; + + return resolved_location{root.string(), path}; +} + +} // anonymous namespace + +void verify_schema(onelibrary_context& context) +{ + // A real export has twenty-two tables; demanding the ones this library + // does not read would reject a database that is merely older or newer. + constexpr std::array required_tables{ + "content", "artist", "album", "genre", "label", + "key", "playlist", "playlist_content", "property"}; + + std::unordered_set present; + context.db << "SELECT name FROM sqlite_master WHERE type = 'table'" >> + [&](std::string name) { present.insert(std::move(name)); }; + + for (const auto& table : required_tables) + { + std::string name{table}; + if (present.count(name) == 0) + throw database_inconsistency{ + "The table `" + name + + "` is missing, so this is not a valid OneLibrary database"}; + } + + // Every export carries exactly one `property` row, recording the version + // of its schema. A database without one has not been written properly. + int64_t property_rows = 0; + int64_t versioned_rows = 0; + context.db << "SELECT COUNT(*), COUNT(NULLIF(dbVersion, '')) " + "FROM property" >> + std::tie(property_rows, versioned_rows); + + if (property_rows != 1) + throw database_inconsistency{ + "The table `property` holds " + std::to_string(property_rows) + + " rows rather than one, so this is not a valid OneLibrary " + "database"}; + + if (versioned_rows != 1) + throw database_inconsistency{ + "The table `property` records no `dbVersion`, so this is not a " + "valid OneLibrary database"}; +} + +std::shared_ptr load_context( + const std::string& path, const std::string& passphrase) +{ + const auto location = resolve(path); + + if (!std::filesystem::exists(location.database_path)) + throw database_not_found{location.database_path}; + + auto context = std::make_shared( + location.directory, util::open_encrypted_database( + location.database_path, passphrase)); + + // Fail here, while the caller still has the path in hand. + verify_schema(*context); + + return context; +} + +bool database_exists(const std::string& path) +{ + const auto location = resolve(path); + return std::filesystem::exists(location.database_path); +} + +std::shared_ptr create_context_from_scripts( + const std::string& directory, const std::string& script_directory, + const std::string& passphrase) +{ + const auto script_path = script_directory + "/exportLibrary.db.sql"; + std::ifstream script{script_path}; + if (!script) + throw std::runtime_error{ + "Cannot read the script `" + script_path + "`"}; + + const auto database_path = directory + "/" + database_relative_path; + if (std::filesystem::exists(database_path)) + throw std::runtime_error{ + "A database already exists at `" + database_path + "`"}; + + std::filesystem::create_directories( + std::filesystem::path{database_path}.parent_path()); + + { + auto db = util::create_encrypted_database(database_path, passphrase); + + // One statement per line, as the Engine scripts are written, with + // blank lines and comments between them. + std::string statement; + while (std::getline(script, statement)) + { + if (statement.empty() || statement.rfind("--", 0) == 0) + continue; + + try + { + db << statement; + } + catch (const std::exception& e) + { + throw std::runtime_error{ + "Error in script `" + script_path + + "` whilst executing line \"" + statement + "\": " + + e.what()}; + } + } + } + + return load_context(directory, passphrase); +} + +database load_database(const std::string& path, const std::string& passphrase) +{ + return database{ + std::make_shared(load_context(path, passphrase))}; +} + +database create_database_from_scripts( + const std::string& directory, const std::string& script_directory, + const std::string& passphrase) +{ + return database{std::make_shared( + create_context_from_scripts(directory, script_directory, passphrase))}; +} + +} // namespace djinterop::onelibrary diff --git a/src/djinterop/onelibrary/onelibrary_context.hpp b/src/djinterop/onelibrary/onelibrary_context.hpp new file mode 100644 index 00000000..e48ca737 --- /dev/null +++ b/src/djinterop/onelibrary/onelibrary_context.hpp @@ -0,0 +1,76 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#pragma once + +#include +#include +#include + +#include + +#include + +namespace djinterop::onelibrary +{ +/// State shared by everything belonging to one loaded OneLibrary database. +struct onelibrary_context +{ + onelibrary_context(std::string directory, sqlite::database db) : + directory{std::move(directory)}, db{std::move(db)} + { + } + + /// Root directory of the device. + /// + /// Track paths in the database are relative to this, not to the directory + /// the database file itself sits in. + const std::string directory; + + /// The database, opened with the key that decrypts it. + sqlite::database db; +}; + +/// Open the database on a device and check that it is one. +/// +/// \param path Either the root directory of a device, or the database file. +std::shared_ptr load_context( + const std::string& path, const std::string& passphrase); + +/// Create a database on a device from a script, and open it. +/// +/// \param directory Root directory of the device. +/// \param script_directory Directory holding `exportLibrary.db.sql`. +std::shared_ptr create_context_from_scripts( + const std::string& directory, const std::string& script_directory, + const std::string& passphrase); + +/// Check that a database holds the tables of a OneLibrary one, and the single +/// `property` row that records its version. +/// +/// \throws database_inconsistency If a table or the `property` row is +/// missing. +void verify_schema(onelibrary_context& context); + +/// Refuse an operation that would change the database. +[[noreturn]] inline void read_only() +{ + throw unsupported_operation{ + "OneLibrary databases are currently read-only in libdjinterop"}; +} + +} // namespace djinterop::onelibrary diff --git a/src/djinterop/onelibrary/v1/content_table.cpp b/src/djinterop/onelibrary/v1/content_table.cpp new file mode 100644 index 00000000..6ceaf4ff --- /dev/null +++ b/src/djinterop/onelibrary/v1/content_table.cpp @@ -0,0 +1,279 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#include + +#include +#include + +#include "../../util/sqlite_query.hpp" +#include "../onelibrary_context.hpp" +#include "track_conversion.hpp" + +namespace djinterop::onelibrary::v1 +{ +namespace +{ +/// How each lookup table is joined to `content`. +/// +/// Nothing enforces that a reference resolves, so each is joined outwards. +/// `key` is quoted throughout, as it is also a SQL keyword. +constexpr const char* artist_join = + "artist ON artist.artist_id = c.artist_id_artist"; +constexpr const char* composer_join = + "artist AS composer ON composer.artist_id = c.artist_id_composer"; +constexpr const char* album_join = "album ON album.album_id = c.album_id"; +constexpr const char* genre_join = "genre ON genre.genre_id = c.genre_id"; +constexpr const char* label_join = "label ON label.label_id = c.label_id"; +constexpr const char* key_join = "\"key\" ON \"key\".key_id = c.key_id"; + +/// Every column the row structure needs, in the order it reads them back. +constexpr const char* row_columns = + "c.content_id, c.title, artist.name, composer.name, album.name, " + "genre.name, label.name, \"key\".name, c.djComment, c.bpmx100, c.length, " + "c.trackNo, c.releaseYear, c.rating, c.path, c.fileSize, " + "c.bitrate, c.samplingRate, c.analysisDataFilePath, c.color_id"; + +std::string row_query() +{ + return std::string{"SELECT "} + row_columns + " FROM content AS c " + + "LEFT JOIN " + artist_join + " LEFT JOIN " + composer_join + + " LEFT JOIN " + album_join + " LEFT JOIN " + genre_join + + " LEFT JOIN " + label_join + " LEFT JOIN " + key_join + + " WHERE c.content_id = ?"; +} + +/// A query for one column of `content`. +std::string content_column(const std::string& expression) +{ + return "SELECT " + expression + " FROM content AS c WHERE c.content_id = ?"; +} + +/// A query for the name a lookup table holds. +std::string lookup_column(const std::string& alias, const std::string& join) +{ + return "SELECT " + alias + ".name FROM content AS c LEFT JOIN " + join + + " WHERE c.content_id = ?"; +} + +/// Read one column, if the row is there. +template +std::optional read_column( + onelibrary_context& context, const std::string& sql, int64_t id) +{ + std::optional result; + context.db << sql << id >> [&](std::optional value) + { result = std::move(value); }; + + return result; +} + +/// Treat a column that is present but empty as absent: rekordbox writes an +/// empty string for metadata a track does not carry. +std::optional non_empty(std::optional value) +{ + if (value.has_value() && value->empty()) + return std::nullopt; + + return value; +} + +/// Read one column of text, which is absent when it is empty. +std::optional read_text( + onelibrary_context& context, const std::string& sql, int64_t id) +{ + return non_empty(read_column(context, sql, id)); +} + +} // anonymous namespace + +content_table::content_table(std::shared_ptr context) : + context_{std::move(context)} +{ +} + +std::optional content_table::get(int64_t id) const +{ + std::optional result; + + // The parameter list has to match `row_columns` exactly. + context_->db << row_query() << id >> + [&](int64_t row_id, std::optional title, + std::optional artist, + std::optional composer, + std::optional album, std::optional genre, + std::optional label, std::optional key, + std::optional comment, std::optional bpm_x100, + std::optional length, std::optional track_number, + std::optional release_year, + std::optional rating_stars, + std::optional path, std::optional file_size, + std::optional bitrate, + std::optional sampling_rate, + std::optional analysis_path, + std::optional color_id) + { + content_row row; + row.id = row_id; + row.title = non_empty(std::move(title)); + row.artist = non_empty(std::move(artist)); + row.composer = non_empty(std::move(composer)); + row.album = non_empty(std::move(album)); + row.genre = non_empty(std::move(genre)); + row.label = non_empty(std::move(label)); + row.key = non_empty(std::move(key)); + row.comment = non_empty(std::move(comment)); + row.bpm_x100 = bpm_x100; + if (length) + row.length = std::chrono::seconds{*length}; + row.track_number = track_number; + row.release_year = release_year; + row.rating_stars = rating_stars; + row.path = non_empty(std::move(path)); + row.file_size = file_size; + row.bitrate = bitrate; + row.sampling_rate = sampling_rate; + row.analysis_path = non_empty(std::move(analysis_path)); + row.color_id = color_id; + result = std::move(row); + }; + + return result; +} + +std::vector content_table::all_ids() const +{ + return util::collect_ids( + context_->db, "SELECT content_id FROM content ORDER BY content_id"); +} + +std::vector content_table::ids_by_path(const std::string& path) const +{ + return util::collect_ids( + context_->db, + "SELECT content_id FROM content WHERE path = ? ORDER BY content_id", + to_device_path(path)); +} + +bool content_table::exists(int64_t id) const +{ + return util::any_row( + context_->db, "SELECT 1 FROM content WHERE content_id = ? LIMIT 1", id); +} + +std::optional content_table::get_title(int64_t id) const +{ + return read_text(*context_, content_column("c.title"), id); +} + +std::optional content_table::get_artist(int64_t id) const +{ + return read_text(*context_, lookup_column("artist", artist_join), id); +} + +std::optional content_table::get_composer(int64_t id) const +{ + return read_text(*context_, lookup_column("composer", composer_join), id); +} + +std::optional content_table::get_album(int64_t id) const +{ + return read_text(*context_, lookup_column("album", album_join), id); +} + +std::optional content_table::get_genre(int64_t id) const +{ + return read_text(*context_, lookup_column("genre", genre_join), id); +} + +std::optional content_table::get_label(int64_t id) const +{ + return read_text(*context_, lookup_column("label", label_join), id); +} + +std::optional content_table::get_key(int64_t id) const +{ + return read_text(*context_, lookup_column("\"key\"", key_join), id); +} + +std::optional content_table::get_comment(int64_t id) const +{ + return read_text(*context_, content_column("c.djComment"), id); +} + +std::optional content_table::get_bpm_x100(int64_t id) const +{ + return read_column(*context_, content_column("c.bpmx100"), id); +} + +std::optional content_table::get_length(int64_t id) const +{ + const auto seconds = + read_column(*context_, content_column("c.length"), id); + if (!seconds) + return std::nullopt; + + return std::chrono::seconds{*seconds}; +} + +std::optional content_table::get_track_number(int64_t id) const +{ + return read_column(*context_, content_column("c.trackNo"), id); +} + +std::optional content_table::get_release_year(int64_t id) const +{ + return read_column(*context_, content_column("c.releaseYear"), id); +} + +std::optional content_table::get_rating_stars(int64_t id) const +{ + return read_column(*context_, content_column("c.rating"), id); +} + +std::optional content_table::get_path(int64_t id) const +{ + return read_text(*context_, content_column("c.path"), id); +} + +std::optional content_table::get_file_size(int64_t id) const +{ + return read_column(*context_, content_column("c.fileSize"), id); +} + +std::optional content_table::get_bitrate(int64_t id) const +{ + return read_column(*context_, content_column("c.bitrate"), id); +} + +std::optional content_table::get_sampling_rate(int64_t id) const +{ + return read_column( + *context_, content_column("c.samplingRate"), id); +} + +std::optional content_table::get_analysis_path(int64_t id) const +{ + return read_text(*context_, content_column("c.analysisDataFilePath"), id); +} + +std::optional content_table::get_color_id(int64_t id) const +{ + return read_column(*context_, content_column("c.color_id"), id); +} + +} // namespace djinterop::onelibrary::v1 diff --git a/src/djinterop/onelibrary/v1/crate_impl.cpp b/src/djinterop/onelibrary/v1/crate_impl.cpp new file mode 100644 index 00000000..04dab414 --- /dev/null +++ b/src/djinterop/onelibrary/v1/crate_impl.cpp @@ -0,0 +1,149 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#include "crate_impl.hpp" + +#include + +#include +#include +#include +#include + +#include "database_impl.hpp" +#include "track_impl.hpp" +namespace djinterop::onelibrary::v1 +{ +crate_impl::crate_impl( + std::shared_ptr context, int64_t id) : + djinterop::crate_impl{id}, context_{std::move(context)} +{ +} + +crate make_crate(std::shared_ptr context, int64_t id) +{ + return crate{std::make_shared(std::move(context), id)}; +} + +bool crate_impl::is_valid() +{ + return playlist_table{context_}.exists(id()); +} + +std::string crate_impl::name() +{ + const auto row = playlist_table{context_}.get(id()); + if (!row) + throw crate_deleted{id()}; + + return row->name; +} + +std::optional crate_impl::parent() +{ + const auto row = playlist_table{context_}.get(id()); + if (!row) + throw crate_deleted{id()}; + + if (!row->parent_id) + return std::nullopt; + + return make_crate(context_, *row->parent_id); +} + +std::vector crate_impl::children() +{ + std::vector results; + for (const auto& child : playlist_table{context_}.child_ids(id())) + results.push_back(make_crate(context_, child)); + + return results; +} + +std::vector crate_impl::descendants() +{ + std::vector results; + for (const auto& descendant : playlist_table{context_}.descendant_ids(id())) + results.push_back(make_crate(context_, descendant)); + + return results; +} + +std::optional crate_impl::sub_crate_by_name(const std::string& name) +{ + const auto child = playlist_table{context_}.find_child(id(), name); + if (!child) + return std::nullopt; + + return make_crate(context_, *child); +} + +std::vector crate_impl::tracks() +{ + std::vector results; + for (const auto& track_id : playlist_table{context_}.track_ids(id())) + results.push_back(make_track(context_, track_id)); + + return results; +} + +database crate_impl::db() +{ + return database{std::make_shared(context_)}; +} + +void crate_impl::add_track(int64_t) +{ + read_only(); +} + +void crate_impl::add_track(track) +{ + read_only(); +} + +void crate_impl::clear_tracks() +{ + read_only(); +} + +crate crate_impl::create_sub_crate(const std::string&) +{ + read_only(); +} + +crate crate_impl::create_sub_crate_after(const std::string&, const crate&) +{ + read_only(); +} + +void crate_impl::remove_track(track) +{ + read_only(); +} + +void crate_impl::set_name(std::string) +{ + read_only(); +} + +void crate_impl::set_parent(std::optional) +{ + read_only(); +} + +} // namespace djinterop::onelibrary::v1 diff --git a/src/djinterop/onelibrary/v1/crate_impl.hpp b/src/djinterop/onelibrary/v1/crate_impl.hpp new file mode 100644 index 00000000..168ead88 --- /dev/null +++ b/src/djinterop/onelibrary/v1/crate_impl.hpp @@ -0,0 +1,66 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#pragma once + +#include + +#include +#include +#include +#include + +#include "../../impl/crate_impl.hpp" +#include "../onelibrary_context.hpp" +namespace djinterop::onelibrary::v1 +{ +/// A crate in a OneLibrary database. +/// +/// The format has one tree of playlists and no concept of a crate, so +/// a crate here is the same row as the playlist of the same identifier. The +/// database reports `playlists_and_crates_are_distinct=false`. +class crate_impl : public djinterop::crate_impl +{ +public: + crate_impl(std::shared_ptr context, int64_t id); + + void add_track(int64_t track_id) override; + void add_track(track tr) override; + std::vector children() override; + void clear_tracks() override; + crate create_sub_crate(const std::string& name) override; + crate create_sub_crate_after( + const std::string& name, const crate& after) override; + database db() override; + std::vector descendants() override; + bool is_valid() override; + std::string name() override; + std::optional parent() override; + void remove_track(track tr) override; + std::optional sub_crate_by_name(const std::string& name) override; + void set_name(std::string name) override; + void set_parent(std::optional parent) override; + std::vector tracks() override; + +private: + std::shared_ptr context_; +}; + +/// Wrap a playlist row as a crate. +crate make_crate(std::shared_ptr context, int64_t id); + +} // namespace djinterop::onelibrary::v1 diff --git a/src/djinterop/onelibrary/v1/database_impl.cpp b/src/djinterop/onelibrary/v1/database_impl.cpp new file mode 100644 index 00000000..00123154 --- /dev/null +++ b/src/djinterop/onelibrary/v1/database_impl.cpp @@ -0,0 +1,190 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#include "database_impl.hpp" + +#include + +#include +#include +#include +#include +#include + +#include "crate_impl.hpp" +#include "playlist_impl.hpp" +#include "track_impl.hpp" +namespace djinterop::onelibrary::v1 +{ +database_impl::database_impl(std::shared_ptr context) : + djinterop::database_impl{ + {feature::supports_nested_crates, feature::supports_nested_playlists, + feature::playlists_support_duplicate_tracks}}, + context_{std::move(context)} +{ +} + +std::string database_impl::directory() +{ + return context_->directory; +} + +std::string database_impl::uuid() +{ + // The format has no identifier for the library as such, so the library + // each track was exported from -- constant across one export -- is the + // closest thing available. + std::string result; + context_->db << "SELECT masterDbId FROM content " + "WHERE masterDbId IS NOT NULL LIMIT 1" >> + [&](std::optional master_db_id) + { + if (master_db_id) + result = std::to_string(*master_db_id); + }; + + return result; +} + +std::string database_impl::version_name() +{ + const auto version = + property_table{context_}.get_db_version().value_or(std::string{}); + + return version.empty() ? "OneLibrary" : "OneLibrary " + version; +} + +void database_impl::verify() +{ + verify_schema(*context_); +} + +std::optional database_impl::track_by_id(int64_t id) +{ + if (!content_table{context_}.exists(id)) + return std::nullopt; + + return make_track(context_, id); +} + +std::vector database_impl::tracks() +{ + std::vector results; + for (const auto& id : content_table{context_}.all_ids()) + results.push_back(make_track(context_, id)); + + return results; +} + +std::vector database_impl::tracks_by_relative_path( + const std::string& relative_path) +{ + std::vector results; + for (const auto& id : content_table{context_}.ids_by_path(relative_path)) + results.push_back(make_track(context_, id)); + + return results; +} + +std::optional database_impl::crate_by_id(int64_t id) +{ + if (!playlist_table{context_}.exists(id)) + return std::nullopt; + + return make_crate(context_, id); +} + +std::vector database_impl::root_crates() +{ + std::vector results; + for (const auto& id : playlist_table{context_}.root_ids()) + results.push_back(make_crate(context_, id)); + + return results; +} + +std::optional database_impl::root_crate_by_name( + const std::string& name) +{ + const auto id = playlist_table{context_}.find_root(name); + if (!id) + return std::nullopt; + + return make_crate(context_, *id); +} + +std::vector database_impl::root_playlists() +{ + std::vector results; + for (const auto& id : playlist_table{context_}.root_ids()) + results.push_back(make_playlist(context_, id)); + + return results; +} + +std::optional database_impl::root_playlist_by_name( + const std::string& name) +{ + const auto id = playlist_table{context_}.find_root(name); + if (!id) + return std::nullopt; + + return make_playlist(context_, *id); +} + +crate database_impl::create_root_crate(const std::string&) +{ + read_only(); +} + +crate database_impl::create_root_crate_after(const std::string&, const crate&) +{ + read_only(); +} + +playlist database_impl::create_root_playlist(const std::string&) +{ + read_only(); +} + +playlist database_impl::create_root_playlist_after( + const std::string&, const djinterop::playlist_impl&) +{ + read_only(); +} + +track database_impl::create_track(const track_snapshot&) +{ + read_only(); +} + +void database_impl::remove_crate(djinterop::crate) +{ + read_only(); +} + +void database_impl::remove_playlist(const djinterop::playlist_impl&) +{ + read_only(); +} + +void database_impl::remove_track(djinterop::track) +{ + read_only(); +} + +} // namespace djinterop::onelibrary::v1 diff --git a/src/djinterop/onelibrary/v1/database_impl.hpp b/src/djinterop/onelibrary/v1/database_impl.hpp new file mode 100644 index 00000000..1d5c8b92 --- /dev/null +++ b/src/djinterop/onelibrary/v1/database_impl.hpp @@ -0,0 +1,68 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#pragma once + +#include + +#include +#include +#include +#include + +#include "../../impl/database_impl.hpp" +#include "../onelibrary_context.hpp" +namespace djinterop::onelibrary::v1 +{ +/// A loaded OneLibrary database, presented through the unified interface. +class database_impl : public djinterop::database_impl +{ +public: + explicit database_impl(std::shared_ptr context); + + std::optional crate_by_id(int64_t id) override; + crate create_root_crate(const std::string& name) override; + crate create_root_crate_after( + const std::string& name, const crate& after) override; + playlist create_root_playlist(const std::string& name) override; + playlist create_root_playlist_after( + const std::string& name, + const djinterop::playlist_impl& after) override; + track create_track(const track_snapshot& snapshot) override; + std::string directory() override; + void verify() override; + void remove_crate(djinterop::crate cr) override; + void remove_playlist(const djinterop::playlist_impl& pl) override; + void remove_track(djinterop::track tr) override; + std::vector root_crates() override; + std::optional root_crate_by_name( + const std::string& name) override; + std::vector root_playlists() override; + std::optional root_playlist_by_name( + const std::string& name) override; + std::optional track_by_id(int64_t id) override; + std::vector tracks() override; + std::vector tracks_by_relative_path( + const std::string& relative_path) override; + std::string uuid() override; + std::string version_name() override; + +private: + std::shared_ptr context_; +}; + +} // namespace djinterop::onelibrary::v1 diff --git a/src/djinterop/onelibrary/v1/library.cpp b/src/djinterop/onelibrary/v1/library.cpp new file mode 100644 index 00000000..3335828d --- /dev/null +++ b/src/djinterop/onelibrary/v1/library.cpp @@ -0,0 +1,75 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#include + +#include + +#include "../onelibrary_context.hpp" +#include "database_impl.hpp" + +namespace djinterop::onelibrary::v1 +{ +library::library(std::shared_ptr context) : + context_{std::move(context)} +{ +} + +bool library::exists(const std::string& path) +{ + return database_exists(path); +} + +library library::load(const std::string& path, const std::string& passphrase) +{ + return library{load_context(path, passphrase)}; +} + +library library::create_from_scripts( + const std::string& directory, const std::string& script_directory, + const std::string& passphrase) +{ + return library{ + create_context_from_scripts(directory, script_directory, passphrase)}; +} + +content_table library::content() const +{ + return content_table{context_}; +} + +playlist_table library::playlist() const +{ + return playlist_table{context_}; +} + +property_table library::property() const +{ + return property_table{context_}; +} + +djinterop::database library::database() const +{ + return djinterop::database{std::make_shared(context_)}; +} + +const std::string& library::directory() const +{ + return context_->directory; +} + +} // namespace djinterop::onelibrary::v1 diff --git a/src/djinterop/onelibrary/v1/playlist_impl.cpp b/src/djinterop/onelibrary/v1/playlist_impl.cpp new file mode 100644 index 00000000..c1cf4d72 --- /dev/null +++ b/src/djinterop/onelibrary/v1/playlist_impl.cpp @@ -0,0 +1,145 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#include "playlist_impl.hpp" + +#include + +#include +#include +#include +#include + +#include "database_impl.hpp" +#include "track_impl.hpp" +namespace djinterop::onelibrary::v1 +{ +playlist_impl::playlist_impl( + std::shared_ptr context, int64_t id) : + context_{std::move(context)}, id_{id} +{ +} + +playlist make_playlist(std::shared_ptr context, int64_t id) +{ + return playlist{std::make_shared(std::move(context), id)}; +} + +std::string playlist_impl::name() const +{ + const auto row = playlist_table{context_}.get(id_); + if (!row) + throw playlist_deleted{id_}; + + return row->name; +} + +std::optional playlist_impl::parent() +{ + const auto row = playlist_table{context_}.get(id_); + if (!row) + throw playlist_deleted{id_}; + + if (!row->parent_id) + return std::nullopt; + + return make_playlist(context_, *row->parent_id); +} + +std::vector playlist_impl::children() +{ + std::vector results; + for (const auto& child : playlist_table{context_}.child_ids(id_)) + results.push_back(make_playlist(context_, child)); + + return results; +} + +std::optional playlist_impl::sub_playlist_by_name( + const std::string& name) +{ + const auto child = playlist_table{context_}.find_child(id_, name); + if (!child) + return std::nullopt; + + return make_playlist(context_, *child); +} + +std::vector playlist_impl::tracks() const +{ + std::vector results; + for (const auto& track_id : playlist_table{context_}.track_ids(id_)) + results.push_back(make_track(context_, track_id)); + + return results; +} + +database playlist_impl::db() const +{ + return database{std::make_shared(context_)}; +} + +bool playlist_impl::operator==(const djinterop::playlist_impl& other) const +{ + const auto* other_impl = dynamic_cast(&other); + return other_impl != nullptr && other_impl->context_ == context_ && + other_impl->id_ == id_; +} + +void playlist_impl::add_track_back(const djinterop::track_impl&) +{ + read_only(); +} + +void playlist_impl::add_track_after( + const djinterop::track_impl&, const djinterop::track_impl&) +{ + read_only(); +} + +void playlist_impl::clear_tracks() +{ + read_only(); +} + +playlist playlist_impl::create_sub_playlist(const std::string&) +{ + read_only(); +} + +playlist playlist_impl::create_sub_playlist_after( + const std::string&, const djinterop::playlist_impl&) +{ + read_only(); +} + +void playlist_impl::remove_track(const djinterop::track_impl&) +{ + read_only(); +} + +void playlist_impl::set_name(const std::string&) +{ + read_only(); +} + +void playlist_impl::set_parent(const djinterop::playlist_impl*) +{ + read_only(); +} + +} // namespace djinterop::onelibrary::v1 diff --git a/src/djinterop/onelibrary/v1/playlist_impl.hpp b/src/djinterop/onelibrary/v1/playlist_impl.hpp new file mode 100644 index 00000000..d8fba5c6 --- /dev/null +++ b/src/djinterop/onelibrary/v1/playlist_impl.hpp @@ -0,0 +1,71 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#pragma once + +#include + +#include +#include +#include +#include + +#include "../../impl/playlist_impl.hpp" +#include "../onelibrary_context.hpp" +namespace djinterop::onelibrary::v1 +{ +/// A playlist in a OneLibrary database. +/// +/// The format keeps one tree that serves as both playlists and crates, so this +/// reads the same rows as `crate_impl`. +class playlist_impl : public djinterop::playlist_impl +{ +public: + playlist_impl(std::shared_ptr context, int64_t id); + + + void add_track_back(const djinterop::track_impl& tr) override; + void add_track_after( + const djinterop::track_impl& tr, + const djinterop::track_impl& after) override; + std::vector children() override; + void clear_tracks() override; + playlist create_sub_playlist(const std::string& name) override; + playlist create_sub_playlist_after( + const std::string& name, + const djinterop::playlist_impl& after) override; + [[nodiscard]] database db() const override; + [[nodiscard]] std::string name() const override; + std::optional parent() override; + void remove_track(const djinterop::track_impl& tr) override; + void set_name(const std::string& name) override; + void set_parent(const djinterop::playlist_impl* parent_maybe) override; + std::optional sub_playlist_by_name( + const std::string& name) override; + [[nodiscard]] std::vector tracks() const override; + + bool operator==(const djinterop::playlist_impl& other) const override; + +private: + std::shared_ptr context_; + int64_t id_; +}; + +/// Wrap a playlist row as a playlist. +playlist make_playlist(std::shared_ptr context, int64_t id); + +} // namespace djinterop::onelibrary::v1 diff --git a/src/djinterop/onelibrary/v1/playlist_table.cpp b/src/djinterop/onelibrary/v1/playlist_table.cpp new file mode 100644 index 00000000..5d0c794a --- /dev/null +++ b/src/djinterop/onelibrary/v1/playlist_table.cpp @@ -0,0 +1,155 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#include + +#include + +#include "../../util/sqlite_query.hpp" +#include "../onelibrary_context.hpp" +namespace djinterop::onelibrary::v1 +{ +namespace +{ +// rekordbox marks a root playlist either by leaving the parent unset or by +// writing zero, and nothing in the schema forces one or the other, so both +// spellings have to be matched. +constexpr const char* select_roots = + "SELECT playlist_id FROM playlist " + "WHERE (playlist_id_parent IS NULL OR playlist_id_parent = 0) " + "ORDER BY sequenceNo, playlist_id"; + +constexpr const char* find_root_by_name = + "SELECT playlist_id FROM playlist " + "WHERE (playlist_id_parent IS NULL OR playlist_id_parent = 0) " + "AND name = ? ORDER BY sequenceNo, playlist_id LIMIT 1"; + +/// A parent identifier of `PARENT_ID_NONE` means the same as none. +std::optional normalise_parent(std::optional parent_id) +{ + if (!parent_id.has_value() || *parent_id == PARENT_ID_NONE) + return std::nullopt; + + return parent_id; +} + +} // anonymous namespace + +playlist_table::playlist_table(std::shared_ptr context) : + context_{std::move(context)} +{ +} + +std::optional playlist_table::get(int64_t id) const +{ + std::optional result; + + context_->db << "SELECT playlist_id, name, playlist_id_parent, sequenceNo " + "FROM playlist WHERE playlist_id = ?" + << id >> + [&](int64_t row_id, std::optional name, + std::optional parent_id, + std::optional sequence_number) + { + result = playlist_row{ + row_id, name.value_or(std::string{}), normalise_parent(parent_id), + sequence_number}; + }; + + return result; +} + +bool playlist_table::exists(int64_t id) const +{ + return util::any_row( + context_->db, "SELECT 1 FROM playlist WHERE playlist_id = ? LIMIT 1", + id); +} + +std::vector playlist_table::root_ids() const +{ + return util::collect_ids(context_->db, select_roots); +} + +std::vector playlist_table::child_ids(int64_t id) const +{ + return util::collect_ids( + context_->db, + "SELECT playlist_id FROM playlist WHERE playlist_id_parent = ? " + "ORDER BY sequenceNo, playlist_id", + id); +} + +std::vector playlist_table::descendant_ids(int64_t id) const +{ + // One recursive query rather than one per node. `depth` keeps the result + // breadth first, and `sequenceNo` keeps siblings in their own order. + // + // Nothing in the schema stops a playlist from being its own ancestor, so + // the recursion stops once it is deeper than the table has rows, which no + // well-formed tree can be. + return util::collect_ids( + context_->db, + "WITH RECURSIVE descendant(playlist_id, sequenceNo, depth) AS (" + "SELECT playlist_id, sequenceNo, 0 FROM playlist " + "WHERE playlist_id_parent = ? " + "UNION ALL " + "SELECT p.playlist_id, p.sequenceNo, descendant.depth + 1 " + "FROM playlist AS p " + "JOIN descendant ON p.playlist_id_parent = descendant.playlist_id " + "WHERE descendant.depth < (SELECT COUNT(*) FROM playlist)) " + "SELECT playlist_id FROM descendant " + "ORDER BY depth, sequenceNo, playlist_id", + id); +} + +std::optional playlist_table::find_root(const std::string& name) const +{ + return util::first_id(context_->db, find_root_by_name, name); +} + +std::optional playlist_table::find_child( + int64_t parent_id, const std::string& name) const +{ + return util::first_id( + context_->db, + "SELECT playlist_id FROM playlist " + "WHERE playlist_id_parent = ? AND name = ? " + "ORDER BY sequenceNo, playlist_id LIMIT 1", + parent_id, name); +} + +std::vector playlist_table::track_ids(int64_t id) const +{ + return util::collect_ids( + context_->db, + "SELECT content_id FROM playlist_content WHERE playlist_id = ? " + "ORDER BY sequenceNo, rowid", + id); +} + +std::vector playlist_table::playlists_containing( + int64_t track_id) const +{ + return util::collect_ids( + context_->db, + "SELECT DISTINCT playlist_id FROM playlist_content " + "WHERE content_id = ? ORDER BY playlist_id", + track_id); +} + +} // namespace djinterop::onelibrary::v1 diff --git a/src/djinterop/onelibrary/v1/property_table.cpp b/src/djinterop/onelibrary/v1/property_table.cpp new file mode 100644 index 00000000..a161798b --- /dev/null +++ b/src/djinterop/onelibrary/v1/property_table.cpp @@ -0,0 +1,62 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#include + +#include + +#include "../onelibrary_context.hpp" +namespace djinterop::onelibrary::v1 +{ +property_table::property_table(std::shared_ptr context) : + context_{std::move(context)} +{ +} + +std::optional property_table::get() const +{ + std::optional result; + + context_->db << "SELECT deviceName, dbVersion, numberOfContents, " + "createdDate FROM property LIMIT 1" >> + [&](std::optional device_name, + std::optional db_version, + std::optional number_of_contents, + std::optional created_date) + { + property_row row; + row.device_name = std::move(device_name); + row.db_version = std::move(db_version); + row.number_of_contents = number_of_contents; + row.created_date = std::move(created_date); + result = std::move(row); + }; + + return result; +} + +std::optional property_table::get_db_version() const +{ + std::optional result; + context_->db << "SELECT dbVersion FROM property LIMIT 1" >> + [&](std::optional version) + { result = std::move(version); }; + + return result; +} + +} // namespace djinterop::onelibrary::v1 diff --git a/src/djinterop/onelibrary/v1/track_conversion.cpp b/src/djinterop/onelibrary/v1/track_conversion.cpp new file mode 100644 index 00000000..86c3189c --- /dev/null +++ b/src/djinterop/onelibrary/v1/track_conversion.cpp @@ -0,0 +1,209 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#include "track_conversion.hpp" + +#include +#include +#include + +namespace djinterop::onelibrary::v1 +{ +namespace +{ +/// The offset in semitones of a note letter above C, if it is one. +constexpr std::optional semitones_above_c(char note) +{ + switch (note) + { + case 'C': return 0; + case 'D': return 2; + case 'E': return 4; + case 'F': return 5; + case 'G': return 7; + case 'A': return 9; + case 'B': return 11; + default: return std::nullopt; + } +} + +/// Major keys, indexed by semitones above C. +constexpr musical_key major_keys[12] = { + musical_key::c_major, musical_key::d_flat_major, + musical_key::d_major, musical_key::e_flat_major, + musical_key::e_major, musical_key::f_major, + musical_key::f_sharp_major, musical_key::g_major, + musical_key::a_flat_major, musical_key::a_major, + musical_key::b_flat_major, musical_key::b_major}; + +/// Minor keys, indexed by semitones above C. +constexpr musical_key minor_keys[12] = { + musical_key::c_minor, musical_key::d_flat_minor, + musical_key::d_minor, musical_key::e_flat_minor, + musical_key::e_minor, musical_key::f_minor, + musical_key::f_sharp_minor, musical_key::g_minor, + musical_key::a_flat_minor, musical_key::a_minor, + musical_key::b_flat_minor, musical_key::b_minor}; + +} // anonymous namespace + +std::optional parse_musical_key(const std::string& name) +{ + // Notation is a note letter, an optional accidental, and an optional `m` + // for a minor key: `C`, `F#m`, `Bb`. Both the ASCII and the typographic + // accidentals are accepted. + if (name.empty()) + return std::nullopt; + + const auto note = semitones_above_c( + static_cast(std::toupper(static_cast(name[0])))); + if (!note) + return std::nullopt; + + auto semitone = *note; + size_t position = 1; + + // Step over an accidental if one is next. The ASCII and typographic + // spellings mean the same thing and differ only in how many bytes they + // occupy. + const auto consume = [&](std::string_view accidental) + { + if (name.compare(position, accidental.size(), accidental) != 0) + return false; + + position += accidental.size(); + return true; + }; + + if (consume("#") || consume("♯")) // MUSIC SHARP SIGN + semitone += 1; + else if (consume("b") || consume("♭")) // MUSIC FLAT SIGN + semitone -= 1; + + semitone = ((semitone % 12) + 12) % 12; + + const auto remainder = name.substr(position); + if (remainder.empty()) + return major_keys[semitone]; + + if (remainder == "m" || remainder == "M") + return minor_keys[semitone]; + + // Anything else is a notation this library does not know, such as the + // Camelot or Open Key wheels. + return std::nullopt; +} + +std::optional to_bpm(std::optional bpm_x100) +{ + if (!bpm_x100 || *bpm_x100 <= 0) + return std::nullopt; + + return static_cast(*bpm_x100) / 100; +} + +std::optional to_duration( + std::optional length) +{ + if (!length || length->count() <= 0) + return std::nullopt; + + return std::chrono::duration_cast(*length); +} + +std::optional to_positive_int(std::optional value) +{ + if (!value || *value <= 0) + return std::nullopt; + + return static_cast(*value); +} + +std::optional to_rating(std::optional stars) +{ + if (!stars) + return std::nullopt; + + // djinterop rates a track from zero to one hundred, where rekordbox uses + // whole stars. + return static_cast(std::clamp(*stars, 0, 5) * 20); +} + +std::string to_relative_path(const std::string& path) +{ + return !path.empty() && path.front() == '/' ? path.substr(1) : path; +} + +std::string to_device_path(const std::string& relative_path) +{ + return "/" + to_relative_path(relative_path); +} + +std::optional to_sample_rate(std::optional sampling_rate) +{ + if (!sampling_rate || *sampling_rate <= 0) + return std::nullopt; + + return static_cast(*sampling_rate); +} + +std::optional to_sample_count( + std::optional length, + std::optional sampling_rate) +{ + // The database records a duration in whole seconds and no sample count, + // so the count can only be recovered to that precision. + if (!length || length->count() <= 0 || !sampling_rate || + *sampling_rate <= 0) + return std::nullopt; + + return static_cast(length->count() * *sampling_rate); +} + +track_snapshot to_snapshot(const content_row& row) +{ + track_snapshot snapshot; + + snapshot.title = row.title; + snapshot.artist = row.artist; + snapshot.composer = row.composer; + snapshot.album = row.album; + snapshot.genre = row.genre; + snapshot.publisher = row.label; + snapshot.comment = row.comment; + snapshot.bpm = to_bpm(row.bpm_x100); + snapshot.duration = to_duration(row.length); + snapshot.track_number = to_positive_int(row.track_number); + snapshot.year = to_positive_int(row.release_year); + snapshot.rating = to_rating(row.rating_stars); + snapshot.bitrate = to_positive_int(row.bitrate); + snapshot.sample_rate = to_sample_rate(row.sampling_rate); + snapshot.sample_count = to_sample_count(row.length, row.sampling_rate); + + if (row.path) + snapshot.relative_path = to_relative_path(*row.path); + + if (row.file_size && *row.file_size > 0) + snapshot.file_bytes = static_cast(*row.file_size); + + if (row.key) + snapshot.key = parse_musical_key(*row.key); + + return snapshot; +} + +} // namespace djinterop::onelibrary::v1 diff --git a/src/djinterop/onelibrary/v1/track_conversion.hpp b/src/djinterop/onelibrary/v1/track_conversion.hpp new file mode 100644 index 00000000..a5c690d3 --- /dev/null +++ b/src/djinterop/onelibrary/v1/track_conversion.hpp @@ -0,0 +1,68 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +namespace djinterop::onelibrary::v1 +{ +// Each column is interpreted in one place, shared by `to_snapshot` and the +// single-field accessors of `track_impl`, so that the two cannot disagree. +// rekordbox writes zero for a numeric field it does not know. + +[[nodiscard]] std::optional to_bpm(std::optional bpm_x100); + +[[nodiscard]] std::optional to_duration( + std::optional length); + +/// Interpret a count, such as a track number, a year or a bitrate. +[[nodiscard]] std::optional to_positive_int(std::optional value); + +[[nodiscard]] std::optional to_rating(std::optional stars); + +/// Paths are absolute within the device, as `/Contents/...`, whereas djinterop +/// wants them relative to its root. +[[nodiscard]] std::string to_relative_path(const std::string& path); + +/// The inverse of `to_relative_path`. +[[nodiscard]] std::string to_device_path(const std::string& relative_path); + +[[nodiscard]] std::optional to_sample_rate( + std::optional sampling_rate); + +[[nodiscard]] std::optional to_sample_count( + std::optional length, + std::optional sampling_rate); + +/// Build a track snapshot from a content row. +[[nodiscard]] track_snapshot to_snapshot(const content_row& row); + +/// Interpret the key notation that rekordbox writes, such as `F#m` or `Bb`. +/// +/// Returns no value for a notation that is not recognised, rather than +/// guessing. +[[nodiscard]] std::optional parse_musical_key( + const std::string& name); + +} // namespace djinterop::onelibrary::v1 diff --git a/src/djinterop/onelibrary/v1/track_impl.cpp b/src/djinterop/onelibrary/v1/track_impl.cpp new file mode 100644 index 00000000..cb010cc8 --- /dev/null +++ b/src/djinterop/onelibrary/v1/track_impl.cpp @@ -0,0 +1,366 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#include "track_impl.hpp" + +#include + +#include +#include +#include +#include + +#include "../../util/filesystem.hpp" +#include "crate_impl.hpp" +#include "database_impl.hpp" +#include "track_conversion.hpp" + +namespace djinterop::onelibrary::v1 +{ +// Loudness, beatgrids, waveforms, hot cues and loops are absent throughout: +// rekordbox leaves them in the ANLZ files that `analysisDataFilePath` points +// at, and exports an empty `cue` table. Their accessors read as no value +// rather than raising. + +track_impl::track_impl( + std::shared_ptr context, int64_t id) : + djinterop::track_impl{id}, context_{std::move(context)} +{ +} + +track make_track(std::shared_ptr context, int64_t id) +{ + return track{std::make_shared(std::move(context), id)}; +} + +track_snapshot track_impl::snapshot() const +{ + const auto row = content_table{context_}.get(id()); + if (!row) + throw track_deleted{id()}; + + return to_snapshot(*row); +} + +bool track_impl::is_valid() +{ + return content_table{context_}.exists(id()); +} + +database track_impl::db() +{ + return database{std::make_shared(context_)}; +} + +std::vector track_impl::containing_crates() +{ + // A crate and a playlist are the same row in this format, so the crates + // containing a track are the playlists that hold it. + std::vector results; + for (const auto& playlist_id : + playlist_table{context_}.playlists_containing(id())) + results.push_back(make_crate(context_, playlist_id)); + + return results; +} + +std::string track_impl::relative_path() +{ + const auto path = content_table{context_}.get_path(id()); + return path ? to_relative_path(*path) : std::string{}; +} + +std::string track_impl::filename() +{ + return util::get_filename(relative_path()); +} + +std::string track_impl::file_extension() +{ + return util::get_file_extension(relative_path()).value_or(std::string{}); +} + +std::optional track_impl::album() +{ + return content_table{context_}.get_album(id()); +} + +std::optional track_impl::artist() +{ + return content_table{context_}.get_artist(id()); +} + +std::optional track_impl::average_loudness() +{ + return std::nullopt; +} + +std::vector track_impl::beatgrid() +{ + return {}; +} + +std::optional track_impl::bitrate() +{ + return to_positive_int(content_table{context_}.get_bitrate(id())); +} + +std::optional track_impl::bpm() +{ + return to_bpm(content_table{context_}.get_bpm_x100(id())); +} + +std::optional track_impl::comment() +{ + return content_table{context_}.get_comment(id()); +} + +std::optional track_impl::composer() +{ + return content_table{context_}.get_composer(id()); +} + +std::optional track_impl::duration() +{ + return to_duration(content_table{context_}.get_length(id())); +} + +std::optional track_impl::genre() +{ + return content_table{context_}.get_genre(id()); +} + +std::optional track_impl::hot_cue_at(int) +{ + return std::nullopt; +} + +std::vector> track_impl::hot_cues() +{ + return {}; +} + +std::optional track_impl::key() +{ + const auto name = content_table{context_}.get_key(id()); + if (!name) + return std::nullopt; + + return parse_musical_key(*name); +} + +std::optional +track_impl::last_played_at() +{ + // The database counts plays but does not record when the last one was. + return std::nullopt; +} + +std::optional track_impl::loop_at(int) +{ + return std::nullopt; +} + +std::vector> track_impl::loops() +{ + return {}; +} + +std::optional track_impl::main_cue() +{ + return std::nullopt; +} + +std::optional track_impl::publisher() +{ + return content_table{context_}.get_label(id()); +} + +std::optional track_impl::rating() +{ + return to_rating(content_table{context_}.get_rating_stars(id())); +} + +std::optional track_impl::sample_count() +{ + const content_table content{context_}; + return to_sample_count( + content.get_length(id()), content.get_sampling_rate(id())); +} + +std::optional track_impl::sample_rate() +{ + return to_sample_rate(content_table{context_}.get_sampling_rate(id())); +} + +std::optional track_impl::title() +{ + return content_table{context_}.get_title(id()); +} + +std::optional track_impl::track_number() +{ + return to_positive_int(content_table{context_}.get_track_number(id())); +} + +std::vector track_impl::waveform() +{ + return {}; +} + +std::optional track_impl::year() +{ + return to_positive_int(content_table{context_}.get_release_year(id())); +} + +void track_impl::update(const track_snapshot&) +{ + read_only(); +} + +void track_impl::set_album(std::optional) +{ + read_only(); +} + +void track_impl::set_artist(std::optional) +{ + read_only(); +} + +void track_impl::set_average_loudness(std::optional) +{ + read_only(); +} + +void track_impl::set_beatgrid(std::vector) +{ + read_only(); +} + +void track_impl::set_bitrate(std::optional) +{ + read_only(); +} + +void track_impl::set_bpm(std::optional) +{ + read_only(); +} + +void track_impl::set_comment(std::optional) +{ + read_only(); +} + +void track_impl::set_composer(std::optional) +{ + read_only(); +} + +void track_impl::set_duration(std::optional) +{ + read_only(); +} + +void track_impl::set_genre(std::optional) +{ + read_only(); +} + +void track_impl::set_hot_cue_at(int, std::optional) +{ + read_only(); +} + +void track_impl::set_hot_cues(std::vector>) +{ + read_only(); +} + +void track_impl::set_key(std::optional) +{ + read_only(); +} + +void track_impl::set_last_played_at( + std::optional) +{ + read_only(); +} + +void track_impl::set_loop_at(int, std::optional) +{ + read_only(); +} + +void track_impl::set_loops(std::vector>) +{ + read_only(); +} + +void track_impl::set_main_cue(std::optional) +{ + read_only(); +} + +void track_impl::set_publisher(std::optional) +{ + read_only(); +} + +void track_impl::set_rating(std::optional) +{ + read_only(); +} + +void track_impl::set_relative_path(std::string) +{ + read_only(); +} + +void track_impl::set_sample_count(std::optional) +{ + read_only(); +} + +void track_impl::set_sample_rate(std::optional) +{ + read_only(); +} + +void track_impl::set_title(std::optional) +{ + read_only(); +} + +void track_impl::set_track_number(std::optional) +{ + read_only(); +} + +void track_impl::set_waveform(std::vector) +{ + read_only(); +} + +void track_impl::set_year(std::optional) +{ + read_only(); +} + +} // namespace djinterop::onelibrary::v1 diff --git a/src/djinterop/onelibrary/v1/track_impl.hpp b/src/djinterop/onelibrary/v1/track_impl.hpp new file mode 100644 index 00000000..499e7441 --- /dev/null +++ b/src/djinterop/onelibrary/v1/track_impl.hpp @@ -0,0 +1,118 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#pragma once + +#include + +#include +#include +#include +#include +#include + +#include +#include + +#include "../../impl/track_impl.hpp" +#include "../onelibrary_context.hpp" +namespace djinterop::onelibrary::v1 +{ +/// A track in a OneLibrary database. +/// +/// Every accessor reads the database afresh rather than caching, so a track +/// handle stays correct for as long as the row is there. Support is currently +/// read-only, and every mutating operation throws. +class track_impl : public djinterop::track_impl +{ +public: + track_impl(std::shared_ptr context, int64_t id); + + track_snapshot snapshot() const override; + void update(const track_snapshot& snapshot) override; + + std::optional album() override; + void set_album(std::optional album) override; + std::optional artist() override; + void set_artist(std::optional artist) override; + std::optional average_loudness() override; + void set_average_loudness(std::optional average_loudness) override; + std::vector beatgrid() override; + void set_beatgrid(std::vector beatgrid) override; + std::optional bitrate() override; + void set_bitrate(std::optional bitrate) override; + std::optional bpm() override; + void set_bpm(std::optional bpm) override; + std::optional comment() override; + void set_comment(std::optional comment) override; + std::optional composer() override; + void set_composer(std::optional composer) override; + std::vector containing_crates() override; + database db() override; + std::optional duration() override; + void set_duration( + std::optional duration) override; + std::string file_extension() override; + std::string filename() override; + std::optional genre() override; + void set_genre(std::optional genre) override; + std::optional hot_cue_at(int index) override; + void set_hot_cue_at(int index, std::optional cue) override; + std::vector> hot_cues() override; + void set_hot_cues(std::vector> cues) override; + bool is_valid() override; + std::optional key() override; + void set_key(std::optional key) override; + std::optional last_played_at() + override; + void set_last_played_at( + std::optional played_at) + override; + std::optional loop_at(int index) override; + void set_loop_at(int index, std::optional l) override; + std::vector> loops() override; + void set_loops(std::vector> loops) override; + std::optional main_cue() override; + void set_main_cue(std::optional sample_offset) override; + std::optional publisher() override; + void set_publisher(std::optional publisher) override; + std::optional rating() override; + void set_rating(std::optional rating) override; + std::string relative_path() override; + void set_relative_path(std::string relative_path) override; + std::optional sample_count() override; + void set_sample_count( + std::optional sample_count) override; + std::optional sample_rate() override; + void set_sample_rate(std::optional sample_rate) override; + std::optional title() override; + void set_title(std::optional title) override; + std::optional track_number() override; + void set_track_number(std::optional track_number) override; + std::vector waveform() override; + void set_waveform(std::vector waveform) override; + std::optional year() override; + void set_year(std::optional year) override; + +private: + std::shared_ptr context_; +}; + +/// Wrap a content row as a track. +track make_track(std::shared_ptr context, int64_t id); + +} // namespace djinterop::onelibrary::v1 diff --git a/src/djinterop/util/sqlcipher.cpp b/src/djinterop/util/sqlcipher.cpp new file mode 100644 index 00000000..a2908567 --- /dev/null +++ b/src/djinterop/util/sqlcipher.cpp @@ -0,0 +1,95 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +// `sqlite3_key` is declared only when `SQLITE_HAS_CODEC` is defined before +// `sqlite3.h` is first included. +#ifndef SQLITE_HAS_CODEC +#define SQLITE_HAS_CODEC +#endif +#include + +#include "sqlcipher.hpp" + +#include + +namespace djinterop::util +{ +namespace +{ +// The wrapper's own `sqlite_modern_cpp/sqlcipher.h` would do this, but the +// bundled copy of it no longer compiles against the bundled wrapper, so the +// key is set through the C API directly. +sqlite::database open( + const std::string& path, const std::string& passphrase, + sqlite::OpenFlags flags) +{ + sqlite::sqlite_config config; + config.flags = flags; + + try + { + sqlite::database db{path, config}; + if (const auto rc = sqlite3_key( + db.connection().get(), passphrase.data(), + static_cast(passphrase.size())); + rc != SQLITE_OK) + sqlite::errors::throw_sqlite_error(rc); + + return db; + } + catch (const sqlite::sqlite_exception& e) + { + throw unsupported_database{ + "The database `" + path + "` could not be opened: " + e.what()}; + } +} + +} // anonymous namespace + +sqlite::database open_encrypted_database( + const std::string& path, const std::string& passphrase) +{ + // A write-ahead-logged database cannot be read without the shared-memory + // index beside it, so even a read-only connection creates one if it is + // missing. + auto db = open(path, passphrase, sqlite::OpenFlags::READONLY); + + // Setting the key reads nothing, so read something: a wrong passphrase + // would otherwise not be noticed until the first query. + try + { + db << "SELECT COUNT(*) FROM sqlite_master" >> [](int64_t) {}; + } + catch (const sqlite::sqlite_exception&) + { + throw unsupported_database{ + "The file `" + path + + "` is not a SQLCipher database that the given passphrase opens"}; + } + + return db; +} + +sqlite::database create_encrypted_database( + const std::string& path, const std::string& passphrase) +{ + return open( + path, passphrase, + sqlite::OpenFlags::READWRITE | sqlite::OpenFlags::CREATE); +} + +} // namespace djinterop::util diff --git a/src/djinterop/util/sqlcipher.hpp b/src/djinterop/util/sqlcipher.hpp new file mode 100644 index 00000000..fb5c4ace --- /dev/null +++ b/src/djinterop/util/sqlcipher.hpp @@ -0,0 +1,48 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#pragma once + +#include + +#include + +namespace djinterop::util +{ +// Exactly one implementation of this header is compiled in: `sqlcipher.cpp` +// where the build has SQLCipher, and otherwise `sqlcipher_unsupported.cpp`. + +/// Open a SQLCipher database for reading. +/// +/// The connection serves the decrypted contents, including anything still in +/// the write-ahead log. Reading a write-ahead-logged database creates the +/// `-shm` and `-wal` files beside it if they are missing, so the directory +/// holding it must be writable. +/// +/// \throws djinterop::unsupported_database If this build cannot open +/// encrypted databases, or the passphrase does not open the file. +[[nodiscard]] sqlite::database open_encrypted_database( + const std::string& path, const std::string& passphrase); + +/// Create a SQLCipher database, open for writing. +/// +/// \throws djinterop::unsupported_database If this build cannot create +/// encrypted databases, or the file cannot be created. +[[nodiscard]] sqlite::database create_encrypted_database( + const std::string& path, const std::string& passphrase); + +} // namespace djinterop::util diff --git a/src/djinterop/util/sqlcipher_unsupported.cpp b/src/djinterop/util/sqlcipher_unsupported.cpp new file mode 100644 index 00000000..e4abab7c --- /dev/null +++ b/src/djinterop/util/sqlcipher_unsupported.cpp @@ -0,0 +1,40 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#include "sqlcipher.hpp" + +#include + +namespace djinterop::util +{ +sqlite::database open_encrypted_database( + const std::string& path, const std::string&) +{ + throw unsupported_database{ + "This build of libdjinterop cannot read the encrypted database `" + + path + "`, as it was built without EXPERIMENTAL_ENABLE_SQLCIPHER"}; +} + +sqlite::database create_encrypted_database( + const std::string& path, const std::string&) +{ + throw unsupported_database{ + "This build of libdjinterop cannot create the encrypted database `" + + path + "`, as it was built without EXPERIMENTAL_ENABLE_SQLCIPHER"}; +} + +} // namespace djinterop::util diff --git a/src/djinterop/util/sqlite_query.hpp b/src/djinterop/util/sqlite_query.hpp new file mode 100644 index 00000000..0352d50b --- /dev/null +++ b/src/djinterop/util/sqlite_query.hpp @@ -0,0 +1,59 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#pragma once + +#include +#include +#include + +#include + +namespace djinterop::util +{ +/// Run a query whose rows are each a single identifier. +template +std::vector collect_ids( + sqlite::database& db, const char* sql, const Args&... args) +{ + std::vector results; + auto query = db << sql; + ((query << args), ...); + query >> [&](int64_t id) { results.push_back(id); }; + return results; +} + +/// Run a query whose rows are each a single identifier, and take the first. +template +std::optional first_id( + sqlite::database& db, const char* sql, const Args&... args) +{ + const auto ids = collect_ids(db, sql, args...); + if (ids.empty()) + return std::nullopt; + + return ids.front(); +} + +/// Test whether a query matches any row at all. +template +bool any_row(sqlite::database& db, const char* sql, const Args&... args) +{ + return first_id(db, sql, args...).has_value(); +} + +} // namespace djinterop::util diff --git a/test/djinterop/onelibrary/content_table_test.cpp b/test/djinterop/onelibrary/content_table_test.cpp new file mode 100644 index 00000000..629474d9 --- /dev/null +++ b/test/djinterop/onelibrary/content_table_test.cpp @@ -0,0 +1,185 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#define BOOST_TEST_MODULE onelibrary_content_table_test +#include + +#include +#include + +#include + +#include "../boost_test_printable.hpp" +#include "onelibrary_fixture.hpp" + +namespace utf = boost::unit_test; + +namespace +{ +/// A device holding a small library, built once for the whole module. +const onelibrary_device& device() +{ + static const onelibrary_device instance{{ + "INSERT INTO artist VALUES (1, 'Aphex Twin', ''), " + "(2, 'Squarepusher', '');", + "INSERT INTO album VALUES (1, 'Selected Ambient', 1, 0, 0, '');", + "INSERT INTO genre VALUES (1, 'Electro');", + "INSERT INTO label VALUES (1, 'Warp');", + R"(INSERT INTO "key" VALUES (1, 'F#m'), (2, 'Camelot 8A');)", + + // A fully populated track. + "INSERT INTO content (content_id, title, bpmx100, length, trackNo, " + "artist_id_artist, artist_id_composer, album_id, genre_id, " + "label_id, key_id, djComment, rating, releaseYear, path, fileName, " + "fileSize, bitrate, samplingRate) VALUES (1, 'Alpha Track', 12400, " + "391, 7, 1, 2, 1, 1, 1, 1, 'feelin good', 4, 2025, " + "'/Contents/Aphex/alpha.mp3', 'alpha.mp3', 6580703, 320, 44100);", + + // Metadata that is present but empty, and a key notation not + // understood. + "INSERT INTO content (content_id, title, djComment, key_id, path) " + "VALUES (2, 'Beta Track', '', 2, '/Contents/Various/beta.flac');", + }}; + + return instance; +} + +} // anonymous namespace + +BOOST_TEST_DECORATOR(*utf::description("get() resolves the lookup tables")) +BOOST_AUTO_TEST_CASE(get__a_populated_row__resolves_its_lookups) +{ + // Arrange + const auto content = device().library.content(); + + // Act + const auto row = content.get(1); + + // Assert + BOOST_REQUIRE(row); + BOOST_CHECK_EQUAL(row->title.value(), "Alpha Track"); + BOOST_CHECK_EQUAL(row->artist.value(), "Aphex Twin"); + BOOST_CHECK_EQUAL(row->composer.value(), "Squarepusher"); + BOOST_CHECK_EQUAL(row->album.value(), "Selected Ambient"); + BOOST_CHECK_EQUAL(row->genre.value(), "Electro"); + BOOST_CHECK_EQUAL(row->label.value(), "Warp"); + BOOST_CHECK_EQUAL(row->key.value(), "F#m"); + BOOST_CHECK_EQUAL(row->bpm_x100.value(), 12400); + BOOST_CHECK(row->length.value() == std::chrono::seconds{391}); + BOOST_CHECK_EQUAL(row->rating_stars.value(), 4); + BOOST_CHECK_EQUAL(row->path.value(), "/Contents/Aphex/alpha.mp3"); +} + +BOOST_TEST_DECORATOR(*utf::description("get() reads an empty column as absent")) +BOOST_AUTO_TEST_CASE(get__an_empty_column__reads_as_absent) +{ + // Arrange + const auto content = device().library.content(); + + // Act + const auto row = content.get(2); + + // Assert + BOOST_REQUIRE(row); + BOOST_CHECK(!row->comment); + BOOST_CHECK(!row->artist); + BOOST_CHECK(!row->bpm_x100); +} + +BOOST_TEST_DECORATOR(*utf::description("get() for a row that is not there")) +BOOST_AUTO_TEST_CASE(get__an_unknown_row__is_absent) +{ + // Arrange + const auto content = device().library.content(); + + // Act + const auto row = content.get(404); + + // Assert + BOOST_CHECK(!row); +} + +BOOST_TEST_DECORATOR(*utf::description("all_ids() is ordered by identifier")) +BOOST_AUTO_TEST_CASE(all_ids__a_populated_table__is_ordered) +{ + // Arrange + const auto content = device().library.content(); + + // Act + const auto ids = content.all_ids(); + + // Assert + BOOST_REQUIRE_EQUAL(ids.size(), 2u); + BOOST_CHECK_EQUAL(ids[0], 1); + BOOST_CHECK_EQUAL(ids[1], 2); +} + +BOOST_TEST_DECORATOR( + *utf::description("ids_by_path() matches with or without a separator")) +BOOST_AUTO_TEST_CASE(ids_by_path__either_spelling__finds_the_row) +{ + // Arrange + const auto content = device().library.content(); + + // Act + const auto absolute = content.ids_by_path("/Contents/Aphex/alpha.mp3"); + const auto relative = content.ids_by_path("Contents/Aphex/alpha.mp3"); + + // Assert + BOOST_REQUIRE_EQUAL(absolute.size(), 1u); + BOOST_CHECK_EQUAL(absolute[0], 1); + BOOST_CHECK(relative == absolute); +} + +BOOST_TEST_DECORATOR(*utf::description("exists() for present and absent rows")) +BOOST_AUTO_TEST_CASE(exists__present_and_absent_rows__reports_each) +{ + // Arrange + const auto content = device().library.content(); + + // Act / Assert + BOOST_CHECK(content.exists(1)); + BOOST_CHECK(!content.exists(404)); +} + +BOOST_TEST_DECORATOR( + *utf::description("get_xxx() reads one column the same as get() does")) +BOOST_AUTO_TEST_CASE(get_column__a_populated_row__matches_the_whole_row) +{ + // Arrange + const auto content = device().library.content(); + + // Act + const auto row = content.get(1); + + // Assert + BOOST_REQUIRE(row); + BOOST_CHECK(content.get_title(1) == row->title); + BOOST_CHECK(content.get_artist(1) == row->artist); + BOOST_CHECK(content.get_composer(1) == row->composer); + BOOST_CHECK(content.get_album(1) == row->album); + BOOST_CHECK(content.get_label(1) == row->label); + BOOST_CHECK(content.get_length(1) == row->length); + BOOST_CHECK(content.get_rating_stars(1) == row->rating_stars); + BOOST_CHECK(content.get_file_size(1) == row->file_size); + BOOST_CHECK(content.get_bitrate(1) == row->bitrate); + BOOST_CHECK(content.get_sampling_rate(1) == row->sampling_rate); + + // An empty column reads as absent on its own, too. + BOOST_CHECK(!content.get_comment(2)); + BOOST_CHECK(!content.get_title(404)); +} diff --git a/test/djinterop/onelibrary/database_test.cpp b/test/djinterop/onelibrary/database_test.cpp new file mode 100644 index 00000000..2aa44f01 --- /dev/null +++ b/test/djinterop/onelibrary/database_test.cpp @@ -0,0 +1,673 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#define BOOST_TEST_MODULE onelibrary_database_test +#include + +#include +#include + +#include +#include +#include + +#include "../boost_test_printable.hpp" +#include "onelibrary_fixture.hpp" + +namespace utf = boost::unit_test; +namespace ol = djinterop::onelibrary; +namespace olv1 = djinterop::onelibrary::v1; + +namespace +{ +const std::string passphrase = "a passphrase for the fixture"; + +/// A device holding a small OneLibrary export, built once for the whole +/// module. +/// +/// The data covers the awkward cases: metadata that is absent, present but +/// empty, and non-ASCII; a key notation that is not understood; and a nested +/// playlist. +/// +/// Key derivation is deliberately expensive -- the format stretches the +/// passphrase 256,000 times -- so a test that built its own device and opened +/// it would pay for that twice over, every time. +const onelibrary_device& device_fixture() +{ + static const onelibrary_device instance{ + { + "INSERT INTO artist VALUES (1, 'Aphex Twin', 'APHEX TWIN'), " + "(2, 'Кто-то', ''), (3, 'A Composer', '');", + "INSERT INTO album VALUES (1, 'Album One', 1, 0, 0, '');", + "INSERT INTO genre VALUES (1, 'Electro');", + "INSERT INTO label VALUES (1, 'Warp');", + "INSERT INTO \"key\" VALUES (1, 'F#m'), (2, 'Bb'), " + "(3, 'Camelot 8A');", + + // A fully populated track. + "INSERT INTO content (content_id, title, bpmx100, length, " + "trackNo, artist_id_artist, artist_id_composer, album_id, " + "genre_id, label_id, key_id, djComment, rating, releaseYear, " + "path, fileName, fileSize, bitrate, samplingRate, masterDbId, " + "analysisDataFilePath, color_id) VALUES (1, 'Alpha Track', " + "12400, 391, 7, 1, 3, 1, 1, 1, 1, 'feelin good', 4, 2025, " + "'/Contents/Aphex/alpha.mp3', 'alpha.mp3', 6580703, 320, 44100, " + "4056018032, '/PIONEER/USBANLZ/P016/0000875e/ANLZ0000.DAT', 6);", + + // Non-ASCII metadata, an empty comment, and no rating. + "INSERT INTO content (content_id, title, bpmx100, length, " + "artist_id_artist, key_id, djComment, rating, path, " + "samplingRate, masterDbId, color_id) VALUES (2, 'Бета Трек', " + "12800, 245, 2, 2, '', 0, '/Contents/Various/beta.flac', 48000, " + "4056018032, 0);", + + // Almost nothing set, and a key notation this library does not + // know. + "INSERT INTO content (content_id, title, key_id, rating, path) " + "VALUES (3, 'Gamma', 3, 5, '/Contents/Third/gamma.wav');", + + "INSERT INTO playlist VALUES (1, 1, 'Sets', 0, 0, 0), " + "(2, 1, 'Warm Up', 0, 0, 1), (3, 2, 'Peak Time', 0, 0, 0);", + "INSERT INTO playlist_content VALUES (2, 1, 1), (2, 3, 2), " + "(3, 2, 1);", + "UPDATE property SET deviceName = 'FIXTURE', " + "numberOfContents = 3, createdDate = '2026-01-01';", + }, + passphrase}; + + return instance; +} + +/// The shared device, opened once as a library. +const olv1::library& loaded_library() +{ + return device_fixture().library; +} + +/// The shared device, through the same connection as `loaded_library()`. +djinterop::database loaded_database() +{ + return loaded_library().database(); +} + +} // anonymous namespace + +BOOST_TEST_DECORATOR(*utf::description( + "database_exists() finds a device by its root or by its file")) +BOOST_AUTO_TEST_CASE(database_exists__a_device__is_found) +{ + const auto& device = device_fixture().root; + + BOOST_CHECK(ol::database_exists(device)); + BOOST_CHECK(ol::database_exists(device + "/" + ol::database_relative_path)); + BOOST_CHECK(!ol::database_exists(device + "/nowhere")); +} + +BOOST_TEST_DECORATOR( + *utf::description("load_database() reports the identity of a device")) +BOOST_AUTO_TEST_CASE(load_database__a_device__reports_its_identity) +{ + const auto& device = device_fixture().root; + auto db = loaded_database(); + + BOOST_CHECK_EQUAL(db.version_name(), "OneLibrary 1000"); + BOOST_CHECK_EQUAL(db.uuid(), "4056018032"); + BOOST_CHECK_EQUAL(db.directory(), device); + + // The format keeps one tree, used as both playlists and crates. + BOOST_CHECK(!db.supports_feature( + djinterop::feature::playlists_and_crates_are_distinct)); + BOOST_CHECK( + db.supports_feature(djinterop::feature::supports_nested_playlists)); +} + +BOOST_TEST_DECORATOR( + *utf::description("load_database() accepts the database file itself")) +BOOST_AUTO_TEST_CASE(load_database__the_database_file_itself__is_accepted) +{ + const auto& device = device_fixture().root; + auto db = ol::load_database( + device + "/" + ol::database_relative_path, passphrase); + + BOOST_CHECK_EQUAL(db.tracks().size(), 3u); + + // Naming the file still identifies the device it belongs to, because + // track paths are relative to that and not to the file. + BOOST_CHECK_EQUAL(db.directory(), device); +} + +BOOST_TEST_DECORATOR( + *utf::description("load_database() refuses a wrong passphrase")) +BOOST_AUTO_TEST_CASE(load_database__a_wrong_passphrase__is_refused) +{ + const auto& device = device_fixture().root; + + BOOST_CHECK_THROW( + ol::load_database(device, "some other passphrase"), + djinterop::unsupported_database); +} + +BOOST_TEST_DECORATOR( + *utf::description("load_database() throws when no database is present")) +BOOST_AUTO_TEST_CASE(load_database__no_database__throws) +{ + temporary_directory temp_dir; + + BOOST_CHECK_THROW( + ol::load_database(temp_dir.temp_dir, passphrase), + djinterop::database_not_found); +} + +BOOST_TEST_DECORATOR( + *utf::description("tracks() reads every field of a populated track")) +BOOST_AUTO_TEST_CASE(tracks__a_populated_track__reads_every_field) +{ + auto db = loaded_database(); + + const auto track = db.track_by_id(1); + BOOST_REQUIRE(track); + + BOOST_CHECK_EQUAL(track->title().value(), "Alpha Track"); + BOOST_CHECK_EQUAL(track->artist().value(), "Aphex Twin"); + BOOST_CHECK_EQUAL(track->composer().value(), "A Composer"); + BOOST_CHECK_EQUAL(track->album().value(), "Album One"); + BOOST_CHECK_EQUAL(track->genre().value(), "Electro"); + BOOST_CHECK_EQUAL(track->publisher().value(), "Warp"); + BOOST_CHECK_EQUAL(track->comment().value(), "feelin good"); + + // Tempo is stored in hundredths of a beat per minute. + BOOST_CHECK_CLOSE(track->bpm().value(), 124.0, 0.001); + + // Duration is stored in whole seconds. + BOOST_CHECK_EQUAL(track->duration().value().count(), 391000); + + BOOST_CHECK_EQUAL(track->track_number().value(), 7); + BOOST_CHECK_EQUAL(track->year().value(), 2025); + BOOST_CHECK_EQUAL(track->bitrate().value(), 320); + BOOST_CHECK_CLOSE(track->sample_rate().value(), 44100.0, 0.001); + + // Four stars of five, on djinterop's scale of one hundred. + BOOST_CHECK_EQUAL(track->rating().value(), 80); + + BOOST_CHECK(track->key().value() == djinterop::musical_key::f_sharp_minor); + + // Paths are absolute within the device; djinterop wants them relative to + // its root. + BOOST_CHECK_EQUAL(track->relative_path(), "Contents/Aphex/alpha.mp3"); + BOOST_CHECK_EQUAL(track->filename(), "alpha.mp3"); + BOOST_CHECK_EQUAL(track->file_extension(), "mp3"); +} + +BOOST_TEST_DECORATOR( + *utf::description("tracks() reads sparse metadata as absent")) +BOOST_AUTO_TEST_CASE(tracks__sparse_metadata__reads_as_absent) +{ + auto db = loaded_database(); + + const auto track = db.track_by_id(3); + BOOST_REQUIRE(track); + + BOOST_CHECK_EQUAL(track->title().value(), "Gamma"); + BOOST_CHECK(!track->artist()); + BOOST_CHECK(!track->album()); + BOOST_CHECK(!track->bpm()); + BOOST_CHECK(!track->duration()); + BOOST_CHECK(!track->bitrate()); + BOOST_CHECK(!track->sample_rate()); + BOOST_CHECK(!track->sample_count()); + + // A key notation this library does not know reads as no key, rather than + // as a guess. + BOOST_CHECK(!track->key()); + + // An empty comment is metadata the track does not carry. + const auto other = db.track_by_id(2); + BOOST_REQUIRE(other); + BOOST_CHECK(!other->comment()); + BOOST_CHECK_EQUAL(other->title().value(), "Бета Трек"); + BOOST_CHECK_EQUAL(other->artist().value(), "Кто-то"); + BOOST_CHECK(other->key().value() == djinterop::musical_key::b_flat_major); +} + +BOOST_TEST_DECORATOR(*utf::description( + "tracks() implies a sample count from the duration and rate")) +BOOST_AUTO_TEST_CASE(tracks__a_known_duration_and_rate__implies_a_sample_count) +{ + auto db = loaded_database(); + + const auto track = db.track_by_id(1); + BOOST_REQUIRE(track); + + // The database records whole seconds and no sample count, so the count is + // only recoverable to that precision. + BOOST_CHECK_EQUAL(track->sample_count().value(), 391ull * 44100); +} + +BOOST_TEST_DECORATOR( + *utf::description("tracks_by_relative_path() finds a track by its path")) +BOOST_AUTO_TEST_CASE(tracks_by_relative_path__a_known_path__finds_the_track) +{ + auto db = loaded_database(); + + const auto found = db.tracks_by_relative_path("Contents/Aphex/alpha.mp3"); + BOOST_REQUIRE_EQUAL(found.size(), 1u); + BOOST_CHECK_EQUAL(found.front().id(), 1); + + // The same path written the way the database stores it. + BOOST_CHECK_EQUAL( + db.tracks_by_relative_path("/Contents/Aphex/alpha.mp3").size(), 1u); + + BOOST_CHECK(db.tracks_by_relative_path("nothing/here.mp3").empty()); +} + +BOOST_TEST_DECORATOR( + *utf::description("track_by_id() for a track that is not there")) +BOOST_AUTO_TEST_CASE(track_by_id__an_unknown_track__is_absent) +{ + auto db = loaded_database(); + + BOOST_CHECK(!db.track_by_id(999)); +} + +BOOST_TEST_DECORATOR(*utf::description("playlists walk a nested tree")) +BOOST_AUTO_TEST_CASE(playlists__a_nested_tree__is_walked) +{ + auto db = loaded_database(); + + const auto roots = db.root_playlists(); + BOOST_REQUIRE_EQUAL(roots.size(), 2u); + BOOST_CHECK_EQUAL(roots[0].name(), "Sets"); + BOOST_CHECK_EQUAL(roots[1].name(), "Peak Time"); + + // The folder itself holds no tracks; its child does. + BOOST_CHECK(roots[0].tracks().empty()); + + const auto children = roots[0].children(); + BOOST_REQUIRE_EQUAL(children.size(), 1u); + BOOST_CHECK_EQUAL(children[0].name(), "Warm Up"); + + const auto tracks = children[0].tracks(); + BOOST_REQUIRE_EQUAL(tracks.size(), 2u); + BOOST_CHECK_EQUAL(tracks[0].id(), 1); + BOOST_CHECK_EQUAL(tracks[1].id(), 3); + + BOOST_REQUIRE(children[0].parent()); + BOOST_CHECK_EQUAL(children[0].parent()->name(), "Sets"); + BOOST_CHECK(!roots[0].parent()); +} + +BOOST_TEST_DECORATOR(*utf::description("crates see the same tree as playlists")) +BOOST_AUTO_TEST_CASE(crates__the_same_tree_as_playlists__is_seen) +{ + auto db = loaded_database(); + + const auto roots = db.root_crates(); + BOOST_REQUIRE_EQUAL(roots.size(), 2u); + + const auto sets = db.root_crate_by_name("Sets"); + BOOST_REQUIRE(sets); + BOOST_CHECK_EQUAL(sets->descendants().size(), 1u); + + const auto warm_up = sets->sub_crate_by_name("Warm Up"); + BOOST_REQUIRE(warm_up); + BOOST_CHECK_EQUAL(warm_up->tracks().size(), 2u); + + BOOST_CHECK(!db.root_crate_by_name("No Such Crate")); + + const auto track = db.track_by_id(1); + BOOST_REQUIRE(track); + const auto containing = track->containing_crates(); + BOOST_REQUIRE_EQUAL(containing.size(), 1u); + BOOST_CHECK_EQUAL(containing.front().name(), "Warm Up"); +} + +BOOST_TEST_DECORATOR(*utf::description("every kind of write is refused")) +BOOST_AUTO_TEST_CASE(writes__every_kind__are_refused) +{ + auto db = loaded_database(); + + BOOST_CHECK_THROW( + db.create_root_crate("New"), djinterop::unsupported_operation); + BOOST_CHECK_THROW( + db.create_root_playlist("New"), djinterop::unsupported_operation); + BOOST_CHECK_THROW( + db.create_track(djinterop::track_snapshot{}), + djinterop::unsupported_operation); + + auto track = db.tracks().front(); + BOOST_CHECK_THROW( + track.set_title(std::string{"New"}), djinterop::unsupported_operation); + BOOST_CHECK_THROW(track.set_bpm(100.0), djinterop::unsupported_operation); + + auto crate = db.root_crates().front(); + BOOST_CHECK_THROW(crate.set_name("New"), djinterop::unsupported_operation); + BOOST_CHECK_THROW( + crate.create_sub_crate("New"), djinterop::unsupported_operation); + BOOST_CHECK_THROW(db.remove_crate(crate), djinterop::unsupported_operation); +} + +BOOST_TEST_DECORATOR(*utf::description("crates walk a deep tree breadth first")) +BOOST_AUTO_TEST_CASE( + crates__a_tree_several_levels_deep__is_walked_breadth_first) +{ + // The shared fixture is only one level deep, which does not exercise the + // recursion in `descendant_ids` or the order it returns. + // + // Root + // +- Middle A (sequence 1) + // | +- Leaf A (sequence 1) + // +- Middle B (sequence 2) + const onelibrary_device deep{{ + "INSERT INTO playlist VALUES (1, 1, 'Root', 0, 0, 0), " + "(2, 1, 'Middle A', 0, 0, 1), (3, 2, 'Middle B', 0, 0, 1), " + "(4, 1, 'Leaf A', 0, 0, 2);", + }}; + + auto loaded = deep.library.database(); + const auto root = loaded.root_crate_by_name("Root"); + BOOST_REQUIRE(root); + + const auto descendants = root->descendants(); + BOOST_REQUIRE_EQUAL(descendants.size(), 3u); + BOOST_CHECK_EQUAL(descendants[0].name(), "Middle A"); + BOOST_CHECK_EQUAL(descendants[1].name(), "Middle B"); + BOOST_CHECK_EQUAL(descendants[2].name(), "Leaf A"); + + // Children stay one level deep, unlike descendants. + BOOST_CHECK_EQUAL(root->children().size(), 2u); +} + +BOOST_TEST_DECORATOR(*utf::description( + "load_database() reads a database whose log was checkpointed")) +BOOST_AUTO_TEST_CASE(load_database__a_checkpointed_log__is_read) +{ + // A real export is written in WAL mode and checkpointed on eject, which + // removes the log but leaves the header declaring the database + // write-ahead-logged. SQLite will not open one of those read-only unless + // it can create the log again. + // + // Creating a database in WAL mode does the same: closing the connection + // that wrote it checkpoints the log into the database. + temporary_directory temp_dir; + const auto device = temp_dir.temp_dir + "/device"; + const auto scripts = temp_dir.temp_dir + "/scripts"; + write_script_on_schema( + scripts, { + "PRAGMA journal_mode = WAL;", + "INSERT INTO content (content_id, title, path) " + "VALUES (1, 'Checkpointed', '/a.mp3');", + }); + ol::create_database_from_scripts(device, scripts, passphrase); + + // Creating it also loads it, and that read-only connection leaves an empty + // log and index behind, so remove them to leave the header alone. + const auto path = device + "/" + ol::database_relative_path; + std::remove((path + "-wal").c_str()); + std::remove((path + "-shm").c_str()); + + auto loaded = ol::load_database(path, passphrase); + const auto tracks = loaded.tracks(); + BOOST_REQUIRE_EQUAL(tracks.size(), 1u); + BOOST_CHECK_EQUAL(tracks[0].title().value(), "Checkpointed"); +} + +BOOST_TEST_DECORATOR( + *utf::description("verify() rejects a database missing its tables")) +BOOST_AUTO_TEST_CASE(verify__a_database_missing_its_tables__is_rejected) +{ + temporary_directory temp_dir; + const auto scripts = temp_dir.temp_dir + "/scripts"; + write_script( + scripts, {"CREATE TABLE something_else(id INTEGER PRIMARY KEY);"}); + + BOOST_CHECK_THROW( + ol::create_database_from_scripts( + temp_dir.temp_dir + "/device", scripts, passphrase), + djinterop::database_inconsistency); +} + +BOOST_TEST_DECORATOR( + *utf::description("verify() rejects a database missing the key table")) +BOOST_AUTO_TEST_CASE(verify__a_database_missing_the_key_table__is_rejected) +{ + // Tracks are read joined to `key`, so it is as necessary as the others. + temporary_directory temp_dir; + const auto scripts = temp_dir.temp_dir + "/scripts"; + write_script_on_schema(scripts, {"DROP TABLE \"key\";"}); + + BOOST_CHECK_THROW( + ol::create_database_from_scripts( + temp_dir.temp_dir + "/device", scripts, passphrase), + djinterop::database_inconsistency); +} + +BOOST_TEST_DECORATOR(*utf::description( + "verify() rejects a database without exactly one versioned property row")) +BOOST_AUTO_TEST_CASE(verify__a_bad_property_table__is_rejected) +{ + // Every export carries exactly one `property` row, recording `dbVersion`. + const std::vector> bad_property_tables{ + {"DELETE FROM property;"}, + {"INSERT INTO property (dbVersion) VALUES ('1000');"}, + {"UPDATE property SET dbVersion = NULL;"}, + {"UPDATE property SET dbVersion = '';"}, + }; + + for (const auto& statements : bad_property_tables) + { + BOOST_TEST_CONTEXT(statements.front()) + { + temporary_directory temp_dir; + const auto scripts = temp_dir.temp_dir + "/scripts"; + write_script_on_schema(scripts, statements); + + BOOST_CHECK_THROW( + ol::create_database_from_scripts( + temp_dir.temp_dir + "/device", scripts, passphrase), + djinterop::database_inconsistency); + } + } +} + +BOOST_TEST_DECORATOR( + *utf::description("library reaches the device through both interfaces")) +BOOST_AUTO_TEST_CASE(library__a_device__is_read_through_its_database) +{ + // Arrange + const auto& lib = loaded_library(); + + // Act + auto db = lib.database(); + + // Assert + BOOST_CHECK_EQUAL(lib.directory(), device_fixture().root); + BOOST_CHECK_EQUAL(db.directory(), device_fixture().root); + BOOST_CHECK_EQUAL(db.tracks().size(), 3u); +} + +BOOST_TEST_DECORATOR(*utf::description( + "content_table::get_analysis_path() gives the path the device records")) +BOOST_AUTO_TEST_CASE(get_analysis_path__a_track_with_analysis_data__is_read) +{ + // Arrange + const auto& lib = loaded_library(); + + // Act + const auto path = lib.content().get_analysis_path(1); + + // Assert + BOOST_REQUIRE(path); + BOOST_CHECK_EQUAL(*path, "/PIONEER/USBANLZ/P016/0000875e/ANLZ0000.DAT"); +} + +BOOST_TEST_DECORATOR(*utf::description( + "content_table::get_analysis_path() for a track that carries none, and " + "for one that is not there")) +BOOST_AUTO_TEST_CASE(get_analysis_path__no_analysis_data__is_absent) +{ + // Arrange + const auto& lib = loaded_library(); + + // Act, Assert + BOOST_CHECK(!lib.content().get_analysis_path(2)); + BOOST_CHECK(!lib.content().get_analysis_path(1234)); +} + +BOOST_TEST_DECORATOR(*utf::description( + "content_table::get_key() gives back the notation the device holds")) +BOOST_AUTO_TEST_CASE(get_key__any_track__is_the_notation_on_the_device) +{ + // Arrange + const auto& lib = loaded_library(); + + // Act, Assert + BOOST_CHECK_EQUAL(lib.content().get_key(1).value(), "F#m"); + BOOST_CHECK_EQUAL(lib.content().get_key(2).value(), "Bb"); + + // A notation the library does not parse is still given back whole. + BOOST_CHECK_EQUAL(lib.content().get_key(3).value(), "Camelot 8A"); + + BOOST_CHECK(!lib.content().get_key(1234)); +} + +BOOST_TEST_DECORATOR(*utf::description( + "content_table::get_color_id() reads the colour of a track")) +BOOST_AUTO_TEST_CASE(get_color_id__marked_and_unmarked_tracks__reports_each) +{ + // Arrange + const auto& lib = loaded_library(); + + // Act, Assert + BOOST_CHECK_EQUAL(lib.content().get_color_id(1).value(), 6); + + // The low-level API reports what the column holds, and `COLOR_ID_NONE` is + // what an unmarked track carries. + BOOST_CHECK_EQUAL( + lib.content().get_color_id(2).value(), olv1::COLOR_ID_NONE); + BOOST_CHECK(!lib.content().get_color_id(3)); + BOOST_CHECK(!lib.content().get_color_id(1234)); +} + +BOOST_TEST_DECORATOR(*utf::description( + "property_table::get_db_version() reports the schema version")) +BOOST_AUTO_TEST_CASE(get_db_version__a_device__is_the_version_it_records) +{ + // Arrange + const auto& lib = loaded_library(); + + // Act, Assert + BOOST_CHECK_EQUAL( + lib.property().get_db_version().value(), olv1::supported_db_version); +} + +BOOST_TEST_DECORATOR( + *utf::description("snapshot() converts the units of the format")) +BOOST_AUTO_TEST_CASE(snapshot__a_populated_track__converts_its_units) +{ + // Arrange + auto db = loaded_database(); + const auto track = db.track_by_id(1); + BOOST_REQUIRE(track); + + // Act + const auto snapshot = track->snapshot(); + + // Assert + BOOST_CHECK_CLOSE(snapshot.bpm.value(), 124.0, 0.001); + BOOST_CHECK_EQUAL(snapshot.duration.value().count(), 391000); + + // Ratings are whole stars in the database, and out of one hundred here. + BOOST_CHECK_EQUAL(snapshot.rating.value(), 80); + + // Paths in the database begin with a separator, and here they do not. + BOOST_CHECK_EQUAL( + snapshot.relative_path.value(), "Contents/Aphex/alpha.mp3"); + + // No sample count is recorded, so it follows from the duration and rate. + BOOST_CHECK_EQUAL(snapshot.sample_count.value(), 391ull * 44100); + BOOST_CHECK_EQUAL(snapshot.publisher.value(), "Warp"); + BOOST_CHECK(snapshot.key == djinterop::musical_key::f_sharp_minor); + + // rekordbox leaves these in the ANLZ files beside the database. + BOOST_CHECK(snapshot.beatgrid.empty()); + BOOST_CHECK(snapshot.waveform.empty()); + BOOST_CHECK(snapshot.hot_cues.empty()); + BOOST_CHECK(snapshot.loops.empty()); +} + +BOOST_TEST_DECORATOR( + *utf::description("key() for each notation rekordbox may write")) +BOOST_AUTO_TEST_CASE(key__each_notation__is_understood_or_not_guessed) +{ + // Arrange + const onelibrary_device keys{{ + "INSERT INTO \"key\" VALUES (1, 'C'), (2, 'Am'), (3, 'F#m'), " + "(4, 'Bb'), (5, 'F♯m'), (6, 'B♭'), (7, ''), (8, 'H'), (9, '8A'), " + "(10, 'Camelot 8A');", + "INSERT INTO content (content_id, key_id) VALUES (1, 1), (2, 2), " + "(3, 3), (4, 4), (5, 5), (6, 6), (7, 7), (8, 8), (9, 9), (10, 10);", + }}; + auto db = keys.library.database(); + const auto key_of = [&](int64_t id) { return db.track_by_id(id)->key(); }; + + // Act / Assert + BOOST_CHECK(key_of(1) == djinterop::musical_key::c_major); + BOOST_CHECK(key_of(2) == djinterop::musical_key::a_minor); + BOOST_CHECK(key_of(3) == djinterop::musical_key::f_sharp_minor); + BOOST_CHECK(key_of(4) == djinterop::musical_key::b_flat_major); + + // The typographic accidentals mean the same as the ASCII ones. + BOOST_CHECK(key_of(5) == djinterop::musical_key::f_sharp_minor); + BOOST_CHECK(key_of(6) == djinterop::musical_key::b_flat_major); + + // Anything else, including the Camelot and Open Key wheels, is not read. + BOOST_CHECK(!key_of(7)); + BOOST_CHECK(!key_of(8)); + BOOST_CHECK(!key_of(9)); + BOOST_CHECK(!key_of(10)); +} + +BOOST_TEST_DECORATOR( + *utf::description("library::exists() and library::load() find a device")) +BOOST_AUTO_TEST_CASE(library__exists_and_load__find_a_device) +{ + // Arrange + const auto& device = device_fixture().root; + + // Act + const auto lib = olv1::library::load(device, passphrase); + + // Assert + BOOST_CHECK(olv1::library::exists(device)); + BOOST_CHECK(!olv1::library::exists(device + "/nowhere")); + BOOST_CHECK_EQUAL(lib.directory(), device); + BOOST_CHECK_EQUAL(lib.content().all_ids().size(), 3u); +} + +BOOST_TEST_DECORATOR(*utf::description( + "create_database_from_scripts() refuses to overwrite a database")) +BOOST_AUTO_TEST_CASE(create_database_from_scripts__an_existing_one__throws) +{ + // Arrange + const auto& device = device_fixture().root; + + // Act / Assert + BOOST_CHECK_THROW( + ol::create_database_from_scripts( + device, onelibrary_v1_script_directory(), passphrase), + std::runtime_error); +} diff --git a/test/djinterop/onelibrary/onelibrary_fixture.hpp b/test/djinterop/onelibrary/onelibrary_fixture.hpp new file mode 100644 index 00000000..6ba8e4c7 --- /dev/null +++ b/test/djinterop/onelibrary/onelibrary_fixture.hpp @@ -0,0 +1,110 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#pragma once + +#include +#include +#include +#include + +#include + +#include +#include + +#include "../temporary_directory.hpp" + +#define ONELIBRARY_STRINGIFY(x) ONELIBRARY_STRINGIFY_(x) +#define ONELIBRARY_STRINGIFY_(x) #x + +/// Directory holding the reference script for the v1 schema. +inline std::string onelibrary_v1_script_directory() +{ + return std::string{ONELIBRARY_STRINGIFY(TESTDATA_DIR)} + + "/ref/onelibrary/v1"; +} + +/// Write a script directory holding the given statements, one per line. +inline void write_script( + const std::string& script_directory, + const std::vector& statements) +{ + boost::filesystem::create_directories(script_directory); + std::ofstream script{script_directory + "/exportLibrary.db.sql"}; + for (const auto& statement : statements) + script << statement << "\n"; + + if (!script) + throw std::runtime_error{ + "Cannot write a script in " + script_directory}; +} + +/// Write a script directory holding the reference schema, followed by +/// statements of a test's own. +inline void write_script_on_schema( + const std::string& script_directory, + const std::vector& statements) +{ + const auto schema_path = + onelibrary_v1_script_directory() + "/exportLibrary.db.sql"; + std::ifstream schema{schema_path}; + if (!schema) + throw std::runtime_error{"Cannot read the schema at " + schema_path}; + + std::vector lines; + for (std::string line; std::getline(schema, line);) + lines.push_back(line); + + lines.insert(lines.end(), statements.begin(), statements.end()); + write_script(script_directory, lines); +} + +/// A device holding the reference schema and a test's own data, created +/// through the public API. +/// +/// Opening an encrypted database derives its key, which is deliberately +/// expensive, so a test module should build a device once and share it. +struct onelibrary_device +{ + explicit onelibrary_device( + const std::vector& statements, + const std::string& passphrase = "a passphrase for the fixture") : + root{temp_dir.temp_dir + "/device"}, + library{create(temp_dir, root, statements, passphrase)} + { + } + + temporary_directory temp_dir; + + /// Root directory of the device. + std::string root; + + djinterop::onelibrary::v1::library library; + +private: + static djinterop::onelibrary::v1::library create( + const temporary_directory& temp_dir, const std::string& root, + const std::vector& statements, + const std::string& passphrase) + { + const auto script_directory = temp_dir.temp_dir + "/scripts"; + write_script_on_schema(script_directory, statements); + return djinterop::onelibrary::v1::library::create_from_scripts( + root, script_directory, passphrase); + } +}; diff --git a/test/djinterop/onelibrary/playlist_table_test.cpp b/test/djinterop/onelibrary/playlist_table_test.cpp new file mode 100644 index 00000000..71fbe2b9 --- /dev/null +++ b/test/djinterop/onelibrary/playlist_table_test.cpp @@ -0,0 +1,222 @@ +/* + This file is part of libdjinterop. + + libdjinterop is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + libdjinterop is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with libdjinterop. If not, see . + */ + +#define BOOST_TEST_MODULE onelibrary_playlist_table_test +#include + +#include + +#include + +#include "../boost_test_printable.hpp" +#include "onelibrary_fixture.hpp" + +namespace utf = boost::unit_test; + +namespace +{ +/// A device holding a tree of playlists, built once for the whole module. +/// +/// Sets (no parent recorded) +/// +- Warm Up (sequence 1) +/// | +- Peak Time (sequence 1) +/// +- Cool Down (sequence 2) +/// Practice (parent of zero, which means the same as none) +/// +/// The two spellings of a root are both present, as rekordbox writes both. +const onelibrary_device& device() +{ + static const onelibrary_device instance{{ + "INSERT INTO playlist (playlist_id, sequenceNo, name, " + "playlist_id_parent) VALUES (1, 1, 'Sets', NULL), " + "(2, 1, 'Warm Up', 1), (3, 2, 'Cool Down', 1), " + "(4, 1, 'Peak Time', 2), (5, 2, 'Practice', 0);", + + "INSERT INTO content (content_id, title) VALUES (10, 'Alpha'), " + "(11, 'Beta'), (12, 'Gamma');", + + // The second track of `Warm Up` is the one that `Peak Time` also + // holds. + "INSERT INTO playlist_content VALUES (2, 11, 1), (2, 10, 2), " + "(4, 10, 1);", + }}; + + return instance; +} + +} // anonymous namespace + +BOOST_TEST_DECORATOR(*utf::description("get() reads one row of the tree")) +BOOST_AUTO_TEST_CASE(get__a_child__reads_its_row) +{ + // Arrange + const auto playlists = device().library.playlist(); + + // Act + const auto row = playlists.get(2); + + // Assert + BOOST_REQUIRE(row); + BOOST_CHECK_EQUAL(row->id, 2); + BOOST_CHECK_EQUAL(row->name, "Warm Up"); + BOOST_REQUIRE(row->parent_id); + BOOST_CHECK_EQUAL(*row->parent_id, 1); + BOOST_CHECK_EQUAL(row->sequence_number.value(), 1); +} + +BOOST_TEST_DECORATOR( + *utf::description("get() treats a parent of zero as no parent")) +BOOST_AUTO_TEST_CASE(get__a_parent_of_zero__reads_as_no_parent) +{ + // Arrange + const auto playlists = device().library.playlist(); + + // Act + const auto row = playlists.get(5); + + // Assert + BOOST_REQUIRE(row); + BOOST_CHECK(!row->parent_id); +} + +BOOST_TEST_DECORATOR(*utf::description("get() for a row that is not there")) +BOOST_AUTO_TEST_CASE(get__an_unknown_playlist__is_absent) +{ + // Arrange + const auto playlists = device().library.playlist(); + + // Act / Assert + BOOST_CHECK(!playlists.get(404)); + BOOST_CHECK(!playlists.exists(404)); + BOOST_CHECK(playlists.exists(1)); +} + +BOOST_TEST_DECORATOR( + *utf::description("root_ids() finds both spellings of a root")) +BOOST_AUTO_TEST_CASE(root_ids__both_spellings__are_roots) +{ + // Arrange + const auto playlists = device().library.playlist(); + + // Act + const auto ids = playlists.root_ids(); + + // Assert + BOOST_REQUIRE_EQUAL(ids.size(), 2u); + BOOST_CHECK_EQUAL(ids[0], 1); + BOOST_CHECK_EQUAL(ids[1], 5); +} + +BOOST_TEST_DECORATOR(*utf::description("child_ids() is in sibling order")) +BOOST_AUTO_TEST_CASE(child_ids__several_siblings__are_in_sequence) +{ + // Arrange + const auto playlists = device().library.playlist(); + + // Act + const auto ids = playlists.child_ids(1); + + // Assert + BOOST_REQUIRE_EQUAL(ids.size(), 2u); + BOOST_CHECK_EQUAL(ids[0], 2); + BOOST_CHECK_EQUAL(ids[1], 3); +} + +BOOST_TEST_DECORATOR(*utf::description("descendant_ids() is breadth first")) +BOOST_AUTO_TEST_CASE(descendant_ids__a_deep_tree__is_breadth_first) +{ + // Arrange + const auto playlists = device().library.playlist(); + + // Act + const auto ids = playlists.descendant_ids(1); + + // Assert + BOOST_REQUIRE_EQUAL(ids.size(), 3u); + BOOST_CHECK_EQUAL(ids[0], 2); // Warm Up + BOOST_CHECK_EQUAL(ids[1], 3); // Cool Down + BOOST_CHECK_EQUAL(ids[2], 4); // Peak Time, one level deeper +} + +BOOST_TEST_DECORATOR( + *utf::description("find_root() and find_child() look up by name")) +BOOST_AUTO_TEST_CASE(find__a_known_name__is_found_at_its_own_level) +{ + // Arrange + const auto playlists = device().library.playlist(); + + // Act / Assert + BOOST_CHECK(playlists.find_root("Sets") == 1); + BOOST_CHECK(playlists.find_child(1, "Cool Down") == 3); + + // A child is not a root, and a name from elsewhere is not a child. + BOOST_CHECK(!playlists.find_root("Warm Up")); + BOOST_CHECK(!playlists.find_child(1, "Peak Time")); +} + +BOOST_TEST_DECORATOR(*utf::description("track_ids() is in playlist order")) +BOOST_AUTO_TEST_CASE(track_ids__a_populated_playlist__is_in_order) +{ + // Arrange + const auto playlists = device().library.playlist(); + + // Act + const auto ids = playlists.track_ids(2); + + // Assert: the sequence numbers, and not the order the rows were written. + BOOST_REQUIRE_EQUAL(ids.size(), 2u); + BOOST_CHECK_EQUAL(ids[0], 11); + BOOST_CHECK_EQUAL(ids[1], 10); +} + +BOOST_TEST_DECORATOR( + *utf::description("playlists_containing() for a track held twice")) +BOOST_AUTO_TEST_CASE(playlists_containing__a_shared_track__finds_each_holder) +{ + // Arrange + const auto playlists = device().library.playlist(); + + // Act + const auto holders = playlists.playlists_containing(10); + + // Assert + BOOST_REQUIRE_EQUAL(holders.size(), 2u); + BOOST_CHECK_EQUAL(holders[0], 2); + BOOST_CHECK_EQUAL(holders[1], 4); + + // A track in no playlist at all is held by nothing. + BOOST_CHECK(playlists.playlists_containing(12).empty()); +} + +BOOST_TEST_DECORATOR( + *utf::description("descendant_ids() terminates on a tree with a cycle")) +BOOST_AUTO_TEST_CASE(descendant_ids__a_cycle__terminates) +{ + // Arrange: nothing in the schema stops a playlist being its own ancestor. + const onelibrary_device cyclic{{ + "INSERT INTO playlist (playlist_id, sequenceNo, name, " + "playlist_id_parent) VALUES (1, 1, 'One', 2), (2, 1, 'Two', 1);", + }}; + const auto playlists = cyclic.library.playlist(); + + // Act + const auto ids = playlists.descendant_ids(1); + + // Assert + BOOST_REQUIRE(!ids.empty()); + BOOST_CHECK_EQUAL(ids[0], 2); +} diff --git a/testdata/ref/onelibrary/v1/exportLibrary.db.sql b/testdata/ref/onelibrary/v1/exportLibrary.db.sql new file mode 100644 index 00000000..ef07a691 --- /dev/null +++ b/testdata/ref/onelibrary/v1/exportLibrary.db.sql @@ -0,0 +1,35 @@ +-- The schema a real OneLibrary export carries, abbreviated to the tables that +-- libdjinterop reads. The version is the one `property.dbVersion` records, as +-- the `onelibrary::v1` namespace reads it. +-- +-- Statements are one per line, as `create_database_from_scripts` reads them. +-- +-- Nothing in a real export is declared NOT NULL, and no foreign key is +-- enforced, so a fixture can leave any column unset. +-- +-- The published description of the format, which the pyrekordbox project +-- documents as Device Library Plus, lists neither content.djPlayCount nor +-- 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. + +CREATE TABLE content(content_id integer primary key, title varchar, titleForSearch varchar, subtitle varchar, bpmx100 integer, length integer, trackNo integer, discNo integer, artist_id_artist integer, artist_id_remixer integer, artist_id_originalArtist integer, artist_id_composer integer, artist_id_lyricist integer, album_id integer, genre_id integer, label_id integer, key_id integer, color_id integer, image_id integer, djComment varchar, rating integer, releaseYear integer, releaseDate varchar, dateCreated varchar, dateAdded varchar, path varchar, fileName varchar, fileSize integer, fileType integer, bitrate integer, bitDepth integer, samplingRate integer, isrc varchar, djPlayCount integer, isHotCueAutoLoadOn integer, isKuvoDeliverStatusOn integer, kuvoDeliveryComment varchar, masterDbId integer, masterContentId integer, analysisDataFilePath varchar, analysedBits integer, contentLink integer, hasModified integer, cueUpdateCount integer, analysisDataUpdateCount integer, informationUpdateCount integer); + +CREATE TABLE artist(artist_id integer primary key, name varchar, nameForSearch varchar); + +CREATE TABLE album(album_id integer primary key, name varchar, artist_id integer, image_id integer, isComplation integer, nameForSearch varchar); + +CREATE TABLE genre(genre_id integer primary key, name varchar); + +CREATE TABLE label(label_id integer primary key, name varchar); + +CREATE TABLE "key"(key_id integer primary key, name varchar); + +CREATE TABLE playlist(playlist_id integer primary key, sequenceNo integer, name varchar, image_id integer, attribute integer, playlist_id_parent integer); + +CREATE TABLE playlist_content(playlist_id integer, content_id integer, sequenceNo integer); + +CREATE TABLE property(deviceName varchar, dbVersion varchar, numberOfContents integer, createdDate varchar, backGroundColorType integer, myTagMasterDBID integer); + +-- Every export carries exactly one `property` row, describing the device and +-- the version of its schema, so an otherwise empty database carries it too. +INSERT INTO property (dbVersion, numberOfContents) VALUES ('1000', 0);