package link.roadio.riff.player.library import link.roadio.cuetree.Album import link.roadio.cuetree.AlbumKind import link.roadio.cuetree.Artist import link.roadio.cuetree.CatalogView import link.roadio.cuetree.FlatCatalogs import link.roadio.cuetree.Genre import link.roadio.cuetree.GenreRef import link.roadio.cuetree.Life import link.roadio.cuetree.MediaId import link.roadio.cuetree.Node import link.roadio.cuetree.NodeKind import link.roadio.cuetree.Snapshot import link.roadio.cuetree.SourceInfo import link.roadio.cuetree.Span import link.roadio.riff.ui.AzPreparedSection import link.roadio.riff.ui.AzSearchQuery import link.roadio.riff.ui.BrowserRows import link.roadio.riff.ui.pathNode import link.roadio.riff.ui.withBrowseNamePolicy import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertSame import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test import link.roadio.riff.indexer.core.media.MediaId as PlayerMediaId class CuetreePreparedViewsTest { private val hash: (ByteArray) -> Long = { it.contentHashCode().toLong() } @Test fun collectionKeysSearchAndLastRowNavigationDoNotResolveTrackMembershipAgain() { val life = Life(1) val root = Node(NodeKind.DIRECTORY, MediaId(-1), source = SourceInfo("Music", provider = "test", locator = "root"), life = life) val nodes = listOf(root) + List(8_640) { Node(NodeKind.MEDIA, MediaId(it.toLong()), root.id, SourceInfo("$it.flac"), life = life) } val albums = List(540) { album -> val sources = (1 + album * 16..16 + album * 16).toList() Album( album.toLong(), AlbumKind.ORDINARY, "Album ${album.toString().padStart(4, '0')}", sources.map { nodes[it].id }, sources, emptyMap(), life, ) } val artists = albums.map { Artist(it.id, "artist-${it.id}", "Artist ${it.id.toString().padStart(4, '0')}", listOf(it.id), life) } val genres = albums.map { Genre(GenreRef(text = "genre-${it.id}"), "Genre ${it.id.toString().padStart(4, '0')}", listOf(it.id), life, it.id) } val view = FlatCatalogs.encode(Snapshot(1, "test", nodes, albums, artists, genres)).view() var sourceQueries = 0 var albumReads = 0 var artistReads = 0 var genreReads = 0 val counted = object : CatalogView by view { override fun sources(id: MediaId): List { sourceQueries++ return view.sources(id) } override val albums = object : AbstractList() { override val size get() = view.albums.size override fun get(index: Int): Album { albumReads++ return view.albums[index] } } override val artists = object : AbstractList() { override val size get() = view.artists.size override fun get(index: Int): Artist { artistReads++ return view.artists[index] } } override val genres = object : AbstractList() { override val size get() = view.genres.size override fun get(index: Int): Genre { genreReads++ return view.genres[index] } } } val models = CuetreeBrowserTree.build(counted, PlayerLibraryProjection(), emptySet(), hash = hash) assertSame((models.music.root.catalog as CuetreeBrowseCatalog).facts, (models.audiobook.root.catalog as CuetreeBrowseCatalog).facts) sourceQueries = 0 albumReads = 0 val decorated = models.music.root.withBrowseNamePolicy(false) val rows = decorated.children.single { it.id == "category:albums" }.children assertFalse((rows as BrowserRows).ready) assertThrows(IllegalStateException::class.java) { rows[0] } AzPreparedSection(rows).snapshot(AzSearchQuery(rows, emptyList(), null)) assertEquals(540, rows.size) val last = "album:${539L.toULong().toString(16)}" repeat(100) { assertEquals(last, pathNode(decorated, listOf("category:albums", last))?.id) } assertEquals("Album browse repeated Media.ID resolution", 0, sourceQueries) assertTrue("Navigation materialized $albumReads preceding Albums", albumReads <= 3) for ((category, prefix) in listOf("artists" to "artist", "genres" to "genre")) { val collection = decorated.children.single { it.id == "category:$category" }.children artistReads = 0 genreReads = 0 AzPreparedSection(collection).snapshot(AzSearchQuery(collection, emptyList(), null)) assertEquals(540, collection.size) val lastId = "$prefix:${539L.toULong().toString(16)}" repeat(100) { assertEquals(lastId, pathNode(decorated, listOf("category:$category", lastId))?.id) } assertEquals("$category browse repeated Media.ID resolution", 0, sourceQueries) assertTrue("Navigation materialized $artistReads preceding Artists", artistReads <= 3) assertTrue("Navigation materialized $genreReads preceding Genres", genreReads <= 3) } } @Test fun eachPhysicalCopyAndEachAdmittedAlbumHasItsOwnFragmentOnlyContainer() { val life = Life(1) val root = Node(NodeKind.DIRECTORY, MediaId(-1), source = SourceInfo("Music", provider = "test", locator = "root"), life = life) val base = Node(NodeKind.MEDIA, MediaId(10), root.id, SourceInfo("a.flac"), life = life) val first = Node( NodeKind.FRAGMENT, MediaId(20), base.id, span = Span(0, 10), attributes = mapOf( "fragment" to listOf("1"), "totalFragments" to listOf("2"), "title" to listOf("First"), ), life = life, ) val second = first.copy( id = MediaId(30), span = Span(10, 20), attributes = mapOf( "fragment" to listOf("2"), "totalFragments" to listOf("2"), "title" to listOf("Second"), ), ) val sources = listOf(3, 4) val albums = listOf( Album(100, AlbumKind.EXPLICIT, "First Album", listOf(first.id, second.id), sources, emptyMap(), life), Album(200, AlbumKind.EXPLICIT, "Second Album", listOf(first.id, second.id), sources.reversed(), emptyMap(), life), ) // The second Album has its own admitted position order. val adjusted = albums.map { it.copy(tracks = it.sources.map { source -> if (source == 3) first.id else second.id }) } val snapshot = Snapshot(1, "test", listOf(root, base, base.copy(source = SourceInfo("b.flac")), first, second), adjusted) for (view in listOf(snapshot, FlatCatalogs.encode(snapshot).view())) { val model = CuetreeBrowserTree.build(view, PlayerLibraryProjection(), emptySet(), hash = hash).music val folder = model.root.children .single { it.id == "category:folders" } .children .single() assertEquals(4, folder.children.size) assertEquals(setOf("First Album", "Second Album"), folder.children.map { it.label }.toSet()) for (container in folder.children) { val expected = if (container.label == "First Album") listOf("First", "Second") else listOf("Second", "First") assertEquals(expected, container.children.map { it.label }) val tracks = model.root.catalog!!.tracks(container.id) assertEquals(expected, tracks.map { it.label }) assertTrue(tracks.all { it.fragment }) } assertTrue( model.root.catalog!! .tracks(folder.id) .all { it.fragment }, ) } } @Test fun firstAvailableCopyOwnsFolderClassificationRatherThanVotingAcrossCopies() { val life = Life(1) val root = Node(NodeKind.DIRECTORY, MediaId(-1), source = SourceInfo("Root", provider = "test", locator = "root"), life = life) val other = Node(NodeKind.DIRECTORY, MediaId(-2), source = SourceInfo("Other", provider = "test", locator = "other"), life = life) val first = Node(NodeKind.MEDIA, MediaId(10), root.id, SourceInfo("first.flac"), life = life) val copy = first.copy(parentId = other.id, source = SourceInfo("copy.flac")) val projection = PlayerLibraryProjection( audiobookOverrides = mapOf( "folder:ffffffffffffffff" to link.roadio.riff.player.storage.StoredAudiobookOverride.MUSIC, "folder:fffffffffffffffe" to link.roadio.riff.player.storage.StoredAudiobookOverride.BOOK, ), ) for (available in listOf(true, false)) { val snapshot = Snapshot( 1, "test", listOf(root, other, first.copy(life = if (available) life else Life(1, link.roadio.cuetree.Flags.ACSS)), copy), ) val model = CuetreeBrowserTree.build(snapshot, projection, emptySet(), hash = hash).music val media = PlayerMediaId(10, 0, false) assertEquals(!available, media in model.effectiveBookMediaIds) } } @Test fun bookContainerKeysAndRowsSharePreparedBodyOnlyEligibility() { val life = Life(1) val root = Node(NodeKind.DIRECTORY, MediaId(-1), source = SourceInfo("Root", provider = "test", locator = "root"), life = life) val base = Node(NodeKind.MEDIA, MediaId(10), root.id, SourceInfo("audio.flac"), life = life) val parts = List(2) { index -> Node( NodeKind.FRAGMENT, MediaId(20L + index), base.id, span = Span(index * 10L, (index + 1) * 10L), attributes = mapOf("fragment" to listOf("${index + 1}"), "totalFragments" to listOf("2"), "automaticBook" to listOf("1")), life = life, ) } val album = Album(100, AlbumKind.EXPLICIT, "Book", parts.map { it.id }, listOf(2, 3), emptyMap(), life) val model = CuetreeBrowserTree .build( Snapshot(1, "test", listOf(root, base) + parts, listOf(album)), PlayerLibraryProjection(), emptySet(), hash = hash, ).music val children = model.root.children .single { it.id == "category:folders" } .children .single() .children val row = children.single() assertTrue(row.audiobook) assertFalse(row.aiReactionEligible) assertFalse((children as BrowserRows).key(0).actionTier) } }