diff --git a/src/main/java/org/deepsymmetry/cratedigger/Database.java b/src/main/java/org/deepsymmetry/cratedigger/Database.java index fcba4ad..572826d 100644 --- a/src/main/java/org/deepsymmetry/cratedigger/Database.java +++ b/src/main/java/org/deepsymmetry/cratedigger/Database.java @@ -1,7 +1,5 @@ package org.deepsymmetry.cratedigger; -import io.kaitai.struct.KaitaiStruct; -import io.kaitai.struct.RandomAccessFileKaitaiStream; import org.apiguardian.api.API; import org.deepsymmetry.cratedigger.pdb.RekordboxPdb; import org.slf4j.Logger; @@ -14,7 +12,7 @@ import java.util.*; /** - *

Parses rekordbox database export files, providing access to the information they contain.

+ *

Parses rekordbox database {@code export.pdb} files, providing access to the information they contain.

*/ @API(status = API.Status.STABLE) public class Database implements Closeable { @@ -22,14 +20,9 @@ public class Database implements Closeable { private static final Logger logger = LoggerFactory.getLogger(Database.class); /** - * Tracks whether we were configured to parse an {@code exportExt.pdb} file. + * Helper class to parse and interact with the database file conveniently. */ - private final boolean isExportExt; - - /** - * Holds a reference to the parser for the file we were constructed with. - */ - private final RekordboxPdb pdb; + private final DatabaseUtil databaseUtil; /** * Holds a reference to the file this database was constructed from. @@ -39,8 +32,7 @@ public class Database implements Closeable { /** *

Construct a database access instance from the specified recordbox export file. * The file can obtained either from the SD or USB media, or directly from a player - * using {@link FileFetcher#fetch(InetAddress, String, String, File)}. This version - * of the constructor only handles {@code export.pdb} files.

+ * using {@link FileFetcher#fetch(InetAddress, String, String, File)}.

* *

Be sure to call {@link #close()} when you are done using the parsed database * to close the underlying file or users will be unable to unmount the drive holding @@ -52,64 +44,44 @@ public class Database implements Closeable { */ @API(status = API.Status.STABLE) public Database(File sourceFile) throws IOException { - this(sourceFile, false); - } - - /** - *

Construct a database access instance from the specified recordbox export file. - * The file can obtained either from the SD or USB media, or directly from a player - * using {@link FileFetcher#fetch(InetAddress, String, String, File)}.

- * - *

Be sure to call {@link #close()} when you are done using the parsed database - * to close the underlying file or users will be unable to unmount the drive holding - * it until they quit your program.

- * - * @param sourceFile an export.pdb or exportExt.pdb file - * @param isExportExt indicates which type of file is to be parsed - * - * @throws IOException if there is a problem reading the file - */ - @API(status = API.Status.EXPERIMENTAL) - public Database(File sourceFile, boolean isExportExt) throws IOException { this.sourceFile = sourceFile; - this.isExportExt = isExportExt; - pdb = new RekordboxPdb(new RandomAccessFileKaitaiStream(sourceFile.getAbsolutePath()), isExportExt); + databaseUtil = new DatabaseUtil(sourceFile, false); final SortedMap> mutableTrackTitleIndex = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); final SortedMap> mutableTrackArtistIndex = new TreeMap<>(); final SortedMap> mutableTrackAlbumIndex = new TreeMap<>(); final SortedMap> mutableTrackGenreIndex = new TreeMap<>(); trackIndex = indexTracks(mutableTrackTitleIndex, mutableTrackArtistIndex, mutableTrackAlbumIndex, mutableTrackGenreIndex); - trackTitleIndex = freezeSecondaryIndex(mutableTrackTitleIndex); - trackAlbumIndex = freezeSecondaryIndex(mutableTrackAlbumIndex); - trackArtistIndex = freezeSecondaryIndex(mutableTrackArtistIndex); - trackGenreIndex = freezeSecondaryIndex(mutableTrackGenreIndex); + trackTitleIndex = databaseUtil.freezeSecondaryIndex(mutableTrackTitleIndex); + trackAlbumIndex = databaseUtil.freezeSecondaryIndex(mutableTrackAlbumIndex); + trackArtistIndex = databaseUtil.freezeSecondaryIndex(mutableTrackArtistIndex); + trackGenreIndex = databaseUtil.freezeSecondaryIndex(mutableTrackGenreIndex); final SortedMap> mutableArtistNameIndex = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); artistIndex = indexArtists(mutableArtistNameIndex); - artistNameIndex = freezeSecondaryIndex(mutableArtistNameIndex); + artistNameIndex = databaseUtil.freezeSecondaryIndex(mutableArtistNameIndex); final SortedMap> mutableColorNameIndex = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); colorIndex = indexColors(mutableColorNameIndex); - colorNameIndex = freezeSecondaryIndex(mutableColorNameIndex); + colorNameIndex = databaseUtil.freezeSecondaryIndex(mutableColorNameIndex); final SortedMap> mutableAlbumNameIndex = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); final SortedMap> mutableAlbumArtistIndex = new TreeMap<>(); albumIndex = indexAlbums(mutableAlbumNameIndex, mutableAlbumArtistIndex); - albumNameIndex = freezeSecondaryIndex(mutableAlbumNameIndex); - albumArtistIndex = freezeSecondaryIndex(mutableAlbumArtistIndex); + albumNameIndex = databaseUtil.freezeSecondaryIndex(mutableAlbumNameIndex); + albumArtistIndex = databaseUtil.freezeSecondaryIndex(mutableAlbumArtistIndex); final SortedMap> mutableLabelNameIndex = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); labelIndex = indexLabels(mutableLabelNameIndex); - labelNameIndex = freezeSecondaryIndex(mutableLabelNameIndex); + labelNameIndex = databaseUtil.freezeSecondaryIndex(mutableLabelNameIndex); final SortedMap> mutableMusicalKeyNameIndex = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); musicalKeyIndex = indexKeys(mutableMusicalKeyNameIndex); - musicalKeyNameIndex = freezeSecondaryIndex(mutableMusicalKeyNameIndex); + musicalKeyNameIndex = databaseUtil.freezeSecondaryIndex(mutableMusicalKeyNameIndex); final SortedMap> mutableGenreNameIndex = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); genreIndex = indexGenres(mutableGenreNameIndex); - genreNameIndex = freezeSecondaryIndex(mutableGenreNameIndex); + genreNameIndex = databaseUtil.freezeSecondaryIndex(mutableGenreNameIndex); artworkIndex = indexArtwork(); @@ -120,96 +92,6 @@ public Database(File sourceFile, boolean isExportExt) throws IOException { historyPlaylistNameIndex = indexHistoryPlaylistNames(); } - /** - * An interface used to process each row found in a table when traversing them to build our indices. - * This allows the common code for traversing a table to be reused, while specializing the handling - * of each kind of table's rows. - */ - private interface RowHandler { - /** - * Each row found in a table being scanned will be passed to this function. - * - * @param row the row that has just been found - */ - void rowFound(KaitaiStruct row); - } - - /** - * Parse and index all the rows found in a particular table. This method performs a scan of the - * specified table, passing all rows that are encountered to an interface that knows what to do - * with them. - * - * @param type the type of table to be scanned and parsed - * @param handler the code that knows how to index that kind of row - * - * @throws IllegalStateException if there is more than (or less than) one table of that type in the file - */ - private void indexRows(RekordboxPdb.PageType type, RowHandler handler) { - boolean done = false; - for (RekordboxPdb.Table table : pdb.tables()) { - if (table.type() == type) { - if (done) throw new IllegalStateException("More than one table found with type " + type); - final long lastIndex = table.lastPage().index(); // This is how we know when to stop. - RekordboxPdb.PageRef currentRef = table.firstPage(); - boolean moreLeft = true; - do { - // logger.info("Indexing page " + currentRef.index()); - final RekordboxPdb.Page page = currentRef.body(); - - // Process only ordinary data pages. - if (page.isDataPage()) { - for (RekordboxPdb.RowGroup rowGroup : page.rowGroups()) { - for (RekordboxPdb.RowRef rowRef : rowGroup.rows()) { - if (rowRef.present()) { - // We found a row, pass it to the handler to be indexed appropriately. - handler.rowFound(isExportExt? rowRef.bodyExt() : rowRef.body()); - } - } - } - } - - // Was this the final page in the table? If so, stop, otherwise, move on to the next page. - if (currentRef.index() == lastIndex) { - moreLeft = false; - } else { - currentRef = page.nextPage(); - } - } while (moreLeft); - done = true; - } - } - - if (!done) throw new IllegalStateException("No table found of type " + type); - } - - /** - * Adds a row ID to a secondary index which is sorted by some other attribute of the record (for example, - * add a track id to the title index, so the track can be found by title). - * - * @param index the secondary index, which holds all the row IDs that have the specified key - * @param key the secondary index value by which this row can be looked up - * @param id the ID of the row to index under the specified key - * @param the type of the key (often String, but may be Long, e.g. to index tracks by artist ID) - */ - private void addToSecondaryIndex(SortedMap> index, K key, Long id) { - SortedSet existingIds = index.computeIfAbsent(key, k -> new TreeSet<>()); - existingIds.add(id); - } - - /** - * Protects a secondary index against further changes once we have finished indexing all the rows that - * are going in to it. - * - * @param index the index that should no longer be modified. - * @param the type of the key (often String, but may be Long, e.g. to index tracks by artist ID) - * - * @return an unmodifiable top-level view of the unmodifiable children - */ - private SortedMap> freezeSecondaryIndex(SortedMap> index) { - index.replaceAll((k, v) -> Collections.unmodifiableSortedSet(index.get(k))); - return Collections.unmodifiableSortedMap(index); - } - /** * A map from track ID to the actual track object. If this ends up taking too much space, it would be * possible to reorganize the Kaitai Struct mapping specification so that rows are parse instances of @@ -273,7 +155,7 @@ private Map indexTracks(final SortedMap> genreIndex) { final Map index = new HashMap<>(); - indexRows(RekordboxPdb.PageType.TRACKS, row -> { + databaseUtil.indexRows(RekordboxPdb.PageType.TRACKS, row -> { // We found a track; index it by its ID. RekordboxPdb.TrackRow trackRow = (RekordboxPdb.TrackRow)row; final long id = trackRow.id(); @@ -282,25 +164,25 @@ private Map indexTracks(final SortedMap 0) { - addToSecondaryIndex(artistIndex, trackRow.artistId(), id); + databaseUtil.addToSecondaryIndex(artistIndex, trackRow.artistId(), id); } if (trackRow.composerId() > 0) { - addToSecondaryIndex(artistIndex, trackRow.composerId(), id); + databaseUtil.addToSecondaryIndex(artistIndex, trackRow.composerId(), id); } if (trackRow.originalArtistId() > 0) { - addToSecondaryIndex(artistIndex, trackRow.originalArtistId(), id); + databaseUtil.addToSecondaryIndex(artistIndex, trackRow.originalArtistId(), id); } if (trackRow.remixerId() > 0) { - addToSecondaryIndex(artistIndex, trackRow.remixerId(), id); + databaseUtil.addToSecondaryIndex(artistIndex, trackRow.remixerId(), id); } if (trackRow.albumId() > 0) { - addToSecondaryIndex(albumIndex, trackRow.albumId(), id); + databaseUtil.addToSecondaryIndex(albumIndex, trackRow.albumId(), id); } if (trackRow.genreId() > 0) { - addToSecondaryIndex(genreIndex, trackRow.genreId(), id); + databaseUtil.addToSecondaryIndex(genreIndex, trackRow.genreId(), id); } }); @@ -331,7 +213,7 @@ private Map indexTracks(final SortedMap indexArtists(final SortedMap> nameIndex) { final Map index = new HashMap<>(); - indexRows(RekordboxPdb.PageType.ARTISTS, row -> { + databaseUtil.indexRows(RekordboxPdb.PageType.ARTISTS, row -> { RekordboxPdb.ArtistRow artistRow = (RekordboxPdb.ArtistRow)row; final long id = artistRow.id(); index.put(id, artistRow); @@ -339,7 +221,7 @@ private Map indexArtists(final SortedMap indexArtists(final SortedMap indexColors(final SortedMap> nameIndex) { final Map index = new HashMap<>(); - indexRows(RekordboxPdb.PageType.COLORS, row -> { + databaseUtil.indexRows(RekordboxPdb.PageType.COLORS, row -> { RekordboxPdb.ColorRow colorRow = (RekordboxPdb.ColorRow)row; final long id = colorRow.id(); index.put(id, colorRow); @@ -378,7 +260,7 @@ private Map indexColors(final SortedMap indexAlbums(final SortedMap> artistIndex) { final Map index = new HashMap<>(); - indexRows(RekordboxPdb.PageType.ALBUMS, row -> { + databaseUtil.indexRows(RekordboxPdb.PageType.ALBUMS, row -> { RekordboxPdb.AlbumRow albumRow = (RekordboxPdb.AlbumRow) row; final long id = albumRow.id(); index.put(id, albumRow); @@ -424,10 +306,10 @@ private Map indexAlbums(final SortedMap 0) { - addToSecondaryIndex(artistIndex, albumRow.artistId(), id); + databaseUtil.addToSecondaryIndex(artistIndex, albumRow.artistId(), id); } }); @@ -458,7 +340,7 @@ private Map indexAlbums(final SortedMap indexLabels(final SortedMap> nameIndex) { final Map index = new HashMap<>(); - indexRows(RekordboxPdb.PageType.LABELS, row -> { + databaseUtil.indexRows(RekordboxPdb.PageType.LABELS, row -> { RekordboxPdb.LabelRow labelRow = (RekordboxPdb.LabelRow) row; final long id = labelRow.id(); index.put(id, labelRow); @@ -466,7 +348,7 @@ private Map indexLabels(final SortedMap indexLabels(final SortedMap indexKeys(final SortedMap> nameIndex) { final Map index = new HashMap<>(); - indexRows(RekordboxPdb.PageType.KEYS, row -> { + databaseUtil.indexRows(RekordboxPdb.PageType.KEYS, row -> { RekordboxPdb.KeyRow keyRow = (RekordboxPdb.KeyRow) row; final long id = keyRow.id(); index.put(id, keyRow); @@ -505,7 +387,7 @@ private Map indexKeys(final SortedMap indexKeys(final SortedMap indexGenres(final SortedMap> nameIndex) { final Map index = new HashMap<>(); - indexRows(RekordboxPdb.PageType.GENRES, row -> { + databaseUtil.indexRows(RekordboxPdb.PageType.GENRES, row -> { RekordboxPdb.GenreRow genreRow = (RekordboxPdb.GenreRow) row; final long id = genreRow.id(); index.put(id, genreRow); @@ -544,7 +426,7 @@ private Map indexGenres(final SortedMap indexGenres(final SortedMap indexArtwork() { final Map index = new HashMap<>(); - indexRows(RekordboxPdb.PageType.ARTWORK, row -> { + databaseUtil.indexRows(RekordboxPdb.PageType.ARTWORK, row -> { RekordboxPdb.ArtworkRow artworkRow = (RekordboxPdb.ArtworkRow) row; index.put(artworkRow.id(), artworkRow); }); @@ -634,7 +516,7 @@ public String toString() { */ private Map> indexPlaylists() { final Map> result = new HashMap<>(); - indexRows(RekordboxPdb.PageType.PLAYLIST_ENTRIES, row -> { + databaseUtil.indexRows(RekordboxPdb.PageType.PLAYLIST_ENTRIES, row -> { RekordboxPdb.PlaylistEntryRow entryRow = (RekordboxPdb.PlaylistEntryRow) row; ArrayList playlist = (ArrayList) result.get(entryRow.playlistId()); if (playlist == null) { @@ -659,7 +541,7 @@ private Map> indexPlaylists() { */ private Map> indexPlaylistFolders() { final Map> result = new HashMap<>(); - indexRows(RekordboxPdb.PageType.PLAYLIST_TREE, row -> { + databaseUtil.indexRows(RekordboxPdb.PageType.PLAYLIST_TREE, row -> { RekordboxPdb.PlaylistTreeRow treeRow = (RekordboxPdb.PlaylistTreeRow) row; ArrayList parent = (ArrayList) result.get(treeRow.parentId()); if (parent == null) { @@ -685,7 +567,7 @@ private Map> indexPlaylistFolders() { */ private SortedMap indexHistoryPlaylistNames() { final SortedMap result = new TreeMap<>(); - indexRows(RekordboxPdb.PageType.HISTORY_PLAYLISTS, row -> { + databaseUtil.indexRows(RekordboxPdb.PageType.HISTORY_PLAYLISTS, row -> { RekordboxPdb.HistoryPlaylistRow historyRow = (RekordboxPdb.HistoryPlaylistRow) row; result.put(getText(historyRow.name()), historyRow.id()); }); @@ -700,7 +582,7 @@ private SortedMap indexHistoryPlaylistNames() { */ private Map> indexHistoryPlaylists() { final Map> result = new HashMap<>(); - indexRows(RekordboxPdb.PageType.HISTORY_ENTRIES, row -> { + databaseUtil.indexRows(RekordboxPdb.PageType.HISTORY_ENTRIES, row -> { RekordboxPdb.HistoryEntryRow entryRow = (RekordboxPdb.HistoryEntryRow) row; ArrayList playList = (ArrayList) result.get(entryRow.playlistId()); if (playList == null) { @@ -727,7 +609,7 @@ private Map> indexHistoryPlaylists() { */ @Override public void close() throws IOException { - pdb._io().close(); + databaseUtil.close(); } /** diff --git a/src/main/java/org/deepsymmetry/cratedigger/DatabaseExt.java b/src/main/java/org/deepsymmetry/cratedigger/DatabaseExt.java new file mode 100644 index 0000000..0e63905 --- /dev/null +++ b/src/main/java/org/deepsymmetry/cratedigger/DatabaseExt.java @@ -0,0 +1,56 @@ +package org.deepsymmetry.cratedigger; + +import org.apiguardian.api.API; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; + +/** + *

Parses rekordbox database {@code exportExt.pdb} files, providing access to the information they contain.

+ */ +@API(status = API.Status.EXPERIMENTAL) +public class DatabaseExt implements Closeable { + + private static final Logger logger = LoggerFactory.getLogger(DatabaseExt.class); + + /** + * Helper class to parse and interact with the database file conveniently. + */ + private final DatabaseUtil databaseUtil; + + /** + *

Construct a database access instance from the specified recordbox export file. + * The file can obtained either from the SD or USB media, or directly from a player + * using {@link FileFetcher#fetch(InetAddress, String, String, File)}.

+ * + *

Be sure to call {@link #close()} when you are done using the parsed database + * to close the underlying file or users will be unable to unmount the drive holding + * it until they quit your program.

+ * + * @param sourceFile an export.pdb file + * + * @throws IOException if there is a problem reading the file + */ + @API(status = API.Status.EXPERIMENTAL) + public DatabaseExt(File sourceFile) throws IOException { + databaseUtil = new DatabaseUtil(sourceFile, true); + // TODO: Gather and index the tag information. + } + + /** + * Close the file underlying the parsed database. This needs to be called if you want to be able + * to unmount the media on which that file resides, but once it is done, you can no longer access + * lazy elements within the database which have not already been parsed. + * + * @throws IOException if there is a problem closing the file + */ + @Override + public void close() throws IOException { + databaseUtil.close(); + } + +} diff --git a/src/main/java/org/deepsymmetry/cratedigger/DatabaseUtil.java b/src/main/java/org/deepsymmetry/cratedigger/DatabaseUtil.java new file mode 100644 index 0000000..b637eba --- /dev/null +++ b/src/main/java/org/deepsymmetry/cratedigger/DatabaseUtil.java @@ -0,0 +1,158 @@ +package org.deepsymmetry.cratedigger; + +import io.kaitai.struct.KaitaiStruct; +import io.kaitai.struct.RandomAccessFileKaitaiStream; +import org.deepsymmetry.cratedigger.pdb.RekordboxPdb; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; +import java.util.Collections; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeSet; + +/** + * Provides helpful utility functions for working with a rekordbox database export. Used by + * {@link Database} and {@link DatabaseExt} + */ +class DatabaseUtil implements Closeable { + /** + * Tracks whether we were configured to parse an {@code exportExt.pdb} file. + */ + final boolean isExportExt; + + /** + * Holds a reference to the parser for the file we were constructed with. + */ + final RekordboxPdb pdb; + + /** + * Holds a reference to the file this database was constructed from. + */ + final File sourceFile; + + /** + *

Construct a database access helper from the specified recordbox export file. + * The file can obtained either from the SD or USB media, or directly from a player + * using {@link FileFetcher#fetch(InetAddress, String, String, File)}.

+ * + *

Be sure to call {@link #close()} when you are done using the parsed database + * to close the underlying file or users will be unable to unmount the drive holding + * it until they quit your program.

+ * + * @param sourceFile an export.pdb file + * @param isExportExt indicates whether this is an ordinary export.pdb file or a newer exportExt.pdb file. + * + * @throws IOException if there is a problem reading the file + */ + DatabaseUtil(File sourceFile, boolean isExportExt) throws IOException { + this.sourceFile = sourceFile; + this.isExportExt = isExportExt; + pdb = new RekordboxPdb(new RandomAccessFileKaitaiStream(sourceFile.getAbsolutePath()), isExportExt); + } + + /** + * Close the file underlying the parsed database. This needs to be called if you want to be able + * to unmount the media on which that file resides, but once it is done, you can no longer access + * lazy elements within the database which have not already been parsed. + * + * @throws IOException if there is a problem closing the file + */ + @Override + public void close() throws IOException { + pdb._io().close(); + } + + /** + * An interface used to process each row found in a table when traversing them to build our indices. + * This allows the common code for traversing a table to be reused, while specializing the handling + * of each kind of table's rows. + */ + interface RowHandler { + /** + * Each row found in a table being scanned will be passed to this function. + * + * @param row the row that has just been found + */ + void rowFound(KaitaiStruct row); + } + + /** + * Parse and index all the rows found in a particular table. This method performs a scan of the + * specified table, passing all rows that are encountered to an interface that knows what to do + * with them. + * + * @param type the type of table to be scanned and parsed + * @param handler the code that knows how to index that kind of row + * + * @throws IllegalStateException if there is more than (or less than) one table of that type in the file + */ + void indexRows(RekordboxPdb.PageType type, DatabaseUtil.RowHandler handler) { + boolean done = false; + for (RekordboxPdb.Table table : pdb.tables()) { + if (table.type() == type) { + if (done) throw new IllegalStateException("More than one table found with type " + type); + final long lastIndex = table.lastPage().index(); // This is how we know when to stop. + RekordboxPdb.PageRef currentRef = table.firstPage(); + boolean moreLeft = true; + do { + // logger.info("Indexing page " + currentRef.index()); + final RekordboxPdb.Page page = currentRef.body(); + + // Process only ordinary data pages. + if (page.isDataPage()) { + for (RekordboxPdb.RowGroup rowGroup : page.rowGroups()) { + for (RekordboxPdb.RowRef rowRef : rowGroup.rows()) { + if (rowRef.present()) { + // We found a row, pass it to the handler to be indexed appropriately. + handler.rowFound(isExportExt? rowRef.bodyExt() : rowRef.body()); + } + } + } + } + + // Was this the final page in the table? If so, stop, otherwise, move on to the next page. + if (currentRef.index() == lastIndex) { + moreLeft = false; + } else { + currentRef = page.nextPage(); + } + } while (moreLeft); + done = true; + } + } + + if (!done) throw new IllegalStateException("No table found of type " + type); + } + + /** + * Adds a row ID to a secondary index which is sorted by some other attribute of the record (for example, + * add a track id to the title index, so the track can be found by title). + * + * @param index the secondary index, which holds all the row IDs that have the specified key + * @param key the secondary index value by which this row can be looked up + * @param id the ID of the row to index under the specified key + * @param the type of the key (often String, but may be Long, e.g. to index tracks by artist ID) + */ + void addToSecondaryIndex(SortedMap> index, K key, Long id) { + SortedSet existingIds = index.computeIfAbsent(key, k -> new TreeSet<>()); + existingIds.add(id); + } + + /** + * Protects a secondary index against further changes once we have finished indexing all the rows that + * are going in to it. + * + * @param index the index that should no longer be modified. + * @param the type of the key (often String, but may be Long, e.g. to index tracks by artist ID) + * + * @return an unmodifiable top-level view of the unmodifiable children + */ + SortedMap> freezeSecondaryIndex(SortedMap> index) { + index.replaceAll((k, v) -> Collections.unmodifiableSortedSet(index.get(k))); + return Collections.unmodifiableSortedMap(index); + } + +}