Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -194,7 +195,7 @@
}

/* 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);
Expand Down Expand Up @@ -283,22 +284,27 @@
}

@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);

Check failure on line 291 in app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "OggFromWebMWriter" 3 times.

See more on https://sonarcloud.io/project/issues?id=TeamNewPipe_NewPipe&issues=AaFOU7uXeIJQZzbt93iA&open=AaFOU7uXeIJQZzbt93iA&pullRequest=13921
}

if ("A_OPUS".equals(webmTrack.codecId)) {
if ("A_OPUS".equals(codecId)) {
final var metadata = new ArrayList<Pair<String, String>>();
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) {
Expand All @@ -309,7 +315,7 @@
}

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
Expand Down Expand Up @@ -354,7 +360,7 @@
private static byte[] makeOpusTagsHeader(final List<Pair<String, String>> 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());

Expand Down
101 changes: 101 additions & 0 deletions app/src/test/java/org/schabi/newpipe/streams/OggFromWebMWriterTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* SPDX-FileCopyrightText: 2026 NewPipe contributors <https://newpipe.net>
* 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<Log>
private lateinit var pairMock: MockedStatic<Pair<*, *>>

@Before
fun setUp() {
logMock = mockStatic(Log::class.java)
pairMock = mockStatic(Pair::class.java)
pairMock.`when`<Pair<Any?, Any?>> { Pair.create<Any?, Any?>(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<Any?, Any?> {
@Suppress("UNCHECKED_CAST")
val pair = mock(Pair::class.java) as Pair<Any?, Any?>
Pair::class.java.getField("first").apply { isAccessible = true }.set(pair, first)
Pair::class.java.getField("second").apply { isAccessible = true }.set(pair, second)
return pair
}
}
Loading