From 866721275d8069d53bdf2441567a590e9dad4233 Mon Sep 17 00:00:00 2001 From: Martin Vlach Date: Thu, 15 Oct 2026 17:17:41 +0000 Subject: [PATCH] Skip missing metadata when building Opus tags makeMetadata() dereferenced StreamInfo.getUploadDate() unconditionally, but the upload date is null when the service does not provide one, so remuxing such an Opus download to Ogg failed with a NullPointerException. Text fields like the category or the uploader name can be null as well, and makeOpusTagsHeader() called isBlank() on them. Only add the DATE tag when an upload date is known, and skip null values the same way blank ones are already skipped. Fixes #13870 Reported-by: BS.ehlas.cz --- .../newpipe/streams/OggFromWebMWriter.java | 26 +++-- .../newpipe/streams/OggFromWebMWriterTest.kt | 101 ++++++++++++++++++ 2 files changed, 117 insertions(+), 10 deletions(-) create mode 100644 app/src/test/java/org/schabi/newpipe/streams/OggFromWebMWriterTest.kt diff --git a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java index 7cdc84e2227..401b71525aa 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java @@ -7,6 +7,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.streams.WebMReader.Cluster; @@ -194,7 +195,7 @@ public void build() throws IOException { } /* step 3: create packet with metadata */ - final byte[] buffer = makeMetadata(); + final byte[] buffer = makeMetadata(webmTrack.codecId, streamInfo); if (buffer != null) { addPacketSegment(buffer.length); makePacketheader(0x00, header, buffer); @@ -283,22 +284,27 @@ private int makePacketheader(final long granPos, @NonNull final ByteBuffer buffe } @Nullable - private byte[] makeMetadata() { + @VisibleForTesting + static byte[] makeMetadata(@Nullable final String codecId, + @Nullable final StreamInfo streamInfo) { if (DEBUG) { - Log.d("OggFromWebMWriter", "Downloading media with codec ID " + webmTrack.codecId); + Log.d("OggFromWebMWriter", "Downloading media with codec ID " + codecId); } - if ("A_OPUS".equals(webmTrack.codecId)) { + if ("A_OPUS".equals(codecId)) { final var metadata = new ArrayList>(); if (streamInfo != null) { metadata.add(Pair.create("COMMENT", streamInfo.getUrl())); metadata.add(Pair.create("GENRE", streamInfo.getCategory())); metadata.add(Pair.create("ARTIST", streamInfo.getUploaderName())); metadata.add(Pair.create("TITLE", streamInfo.getName())); - metadata.add(Pair.create("DATE", streamInfo - .getUploadDate() - .getLocalDateTime() - .format(DateTimeFormatter.ISO_DATE))); + // the upload date is optional and null when the service does not provide it + if (streamInfo.getUploadDate() != null) { + metadata.add(Pair.create("DATE", streamInfo + .getUploadDate() + .getLocalDateTime() + .format(DateTimeFormatter.ISO_DATE))); + } } if (DEBUG) { @@ -309,7 +315,7 @@ private byte[] makeMetadata() { } return makeOpusTagsHeader(metadata); - } else if ("A_VORBIS".equals(webmTrack.codecId)) { + } else if ("A_VORBIS".equals(codecId)) { return new byte[]{ 0x03, // ¿¿¿??? 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, // "vorbis" binary string @@ -354,7 +360,7 @@ private static byte[] makeOpusMetadataTag(final Pair pair) { private static byte[] makeOpusTagsHeader(final List> keyValueLines) { final var tags = keyValueLines .stream() - .filter(p -> !p.second.isBlank()) + .filter(p -> p.second != null && !p.second.isBlank()) .map(OggFromWebMWriter::makeOpusMetadataTag) .collect(Collectors.toUnmodifiableList()); diff --git a/app/src/test/java/org/schabi/newpipe/streams/OggFromWebMWriterTest.kt b/app/src/test/java/org/schabi/newpipe/streams/OggFromWebMWriterTest.kt new file mode 100644 index 00000000000..34e5b9ea632 --- /dev/null +++ b/app/src/test/java/org/schabi/newpipe/streams/OggFromWebMWriterTest.kt @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.streams + +import android.util.Log +import android.util.Pair +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.time.OffsetDateTime +import java.time.ZoneOffset +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.MockedStatic +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.mockito.Mockito.`when` +import org.schabi.newpipe.extractor.localization.DateWrapper +import org.schabi.newpipe.extractor.stream.StreamInfo + +/** + * Tests the Opus metadata header built by [OggFromWebMWriter]. + * + * [Pair] and [Log] are only stubs in local unit tests and are mocked statically. + */ +class OggFromWebMWriterTest { + private lateinit var logMock: MockedStatic + private lateinit var pairMock: MockedStatic> + + @Before + fun setUp() { + logMock = mockStatic(Log::class.java) + pairMock = mockStatic(Pair::class.java) + pairMock.`when`> { Pair.create(any(), any()) } + .thenAnswer { pair(it.getArgument(0), it.getArgument(1)) } + } + + @After + fun tearDown() { + pairMock.close() + logMock.close() + } + + @Test + fun opusMetadataWithoutUploadDate() { + val info = mock(StreamInfo::class.java) + `when`(info.url).thenReturn("https://example.com/watch?v=1") + `when`(info.category).thenReturn("Music") + `when`(info.uploaderName).thenReturn("Uploader") + `when`(info.name).thenReturn("Title") + `when`(info.uploadDate).thenReturn(null) + + val header = OggFromWebMWriter.makeMetadata("A_OPUS", info)!! + + val text = String(header, Charsets.UTF_8) + assertTrue(text.startsWith("OpusTags")) + assertEquals(4, tagCount(header)) + assertTrue(text.contains("COMMENT=https://example.com/watch?v=1")) + assertTrue(text.contains("TITLE=Title")) + assertFalse(text.contains("DATE=")) + } + + @Test + fun opusMetadataSkipsNullTextFields() { + val info = mock(StreamInfo::class.java) + `when`(info.url).thenReturn("https://example.com/watch?v=1") + `when`(info.category).thenReturn(null) + `when`(info.uploaderName).thenReturn(null) + `when`(info.name).thenReturn("Title") + `when`(info.uploadDate).thenReturn( + DateWrapper(OffsetDateTime.of(2026, 10, 1, 12, 0, 0, 0, ZoneOffset.UTC)) + ) + + val header = OggFromWebMWriter.makeMetadata("A_OPUS", info)!! + + val text = String(header, Charsets.UTF_8) + assertEquals(3, tagCount(header)) + assertTrue(text.contains("DATE=2026-10-01")) + assertFalse(text.contains("GENRE=")) + assertFalse(text.contains("ARTIST=")) + } + + // number of comments, stored after "OpusTags" and the empty vendor string + private fun tagCount(header: ByteArray): Int = ByteBuffer.wrap(header, 12, 4).order(ByteOrder.LITTLE_ENDIAN).int + + // the stub constructor of Pair throws, so its final fields are set directly + private fun pair(first: Any?, second: Any?): Pair { + @Suppress("UNCHECKED_CAST") + val pair = mock(Pair::class.java) as Pair + Pair::class.java.getField("first").apply { isAccessible = true }.set(pair, first) + Pair::class.java.getField("second").apply { isAccessible = true }.set(pair, second) + return pair + } +}