diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java
index 87703e218..7632a4359 100644
--- a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java
+++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java
@@ -11,6 +11,7 @@ import org.schabi.newpipe.streams.Mp4DashReader.Mp4Track;
import org.schabi.newpipe.streams.Mp4DashReader.TrackKind;
import org.schabi.newpipe.streams.Mp4DashReader.TrunEntry;
import org.schabi.newpipe.streams.io.SharpStream;
+import org.schabi.newpipe.util.StreamInfoMetadataHelper;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@@ -930,6 +931,14 @@ public class Mp4FromDashWriter {
/**
* Create the 'udta' box with metadata fields.
+ * {@code udta} is a user data box that can contain various types of metadata,
+ * including title, artist, date, and cover art.
+ * @see Apple Quick Time Format Specification for user data atoms
+ * @see Multimedia Wiki FFmpeg Metadata
+ * @see atomicparsley docs
+ * for a short and understandable reference about metadata keys and values
* @throws IOException
*/
private void makeUdta() throws IOException {
@@ -937,10 +946,6 @@ public class Mp4FromDashWriter {
return;
}
- final String title = streamInfo.getName();
- final String artist = streamInfo.getUploaderName();
- final String date = streamInfo.getUploadDate().getLocalDateTime().toLocalDate().toString();
-
// udta
final int startUdta = auxOffset();
auxWrite(ByteBuffer.allocate(8).putInt(0).putInt(0x75647461).array()); // "udta"
@@ -957,6 +962,16 @@ public class Mp4FromDashWriter {
final int startIlst = auxOffset();
auxWrite(ByteBuffer.allocate(8).putInt(0).putInt(0x696C7374).array()); // "ilst"
+ // write metadata items
+
+ final var metaHelper = new StreamInfoMetadataHelper(streamInfo);
+ final String title = metaHelper.getTitle();
+ final String artist = metaHelper.getArtist();
+ final String date = metaHelper.getReleaseDate().getLocalDateTime()
+ .toLocalDate().toString();
+ final String recordLabel = metaHelper.getRecordLabel();
+ final String copyright = metaHelper.getCopyright();
+
if (title != null && !title.isEmpty()) {
writeMetaItem("©nam", title);
}
@@ -967,8 +982,12 @@ public class Mp4FromDashWriter {
// this means 'year' in mp4 metadata, who the hell thought that?
writeMetaItem("©day", date);
}
-
-
+ if (recordLabel != null && !recordLabel.isEmpty()) {
+ writeMetaItem("©lab", recordLabel);
+ }
+ if (copyright != null && !copyright.isEmpty()) {
+ writeMetaItem("©cpy", copyright);
+ }
if (thumbnail != null) {
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
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 64105f68d..c043fff87 100644
--- a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java
+++ b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java
@@ -9,12 +9,14 @@ import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import org.schabi.newpipe.extractor.stream.SongMetadata;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.streams.WebMReader.Cluster;
import org.schabi.newpipe.streams.WebMReader.Segment;
import org.schabi.newpipe.streams.WebMReader.SimpleBlock;
import org.schabi.newpipe.streams.WebMReader.WebMTrack;
import org.schabi.newpipe.streams.io.SharpStream;
+import org.schabi.newpipe.util.StreamInfoMetadataHelper;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
@@ -40,12 +42,18 @@ import java.util.Arrays;
*
*
* - FLAC: RFC 9639
+ * -
+ * Vorbis: Vorbis I.
+ *
+ * Vorbis uses FLAC picture blocks for embedding cover art in the metadata.
+ *
* - Opus: All specs can be found at
* https://opus-codec.org/docs/.
* RFC7845
* defines the Ogg encapsulation for Opus streams, i.e.the container format and metadata.
+ *
+ * Opus uses multiple Vorbis I features, e.g. the comment header format for metadata.
*
- * - Vorbis: Vorbis I
*
*
* @author kapodamy
@@ -349,8 +357,8 @@ public class OggFromWebMWriter implements Closeable {
* @see
* Vorbis I 4.2. Header decode and decode setup and
*
- * Vorbis 5. comment field and header specification
- * for VORBIS metadata header format
+ * Vorbis I 5. comment field and header specification
+ * for VORBIS metadata header format. Vorbis I 5. lists all the possible metadata tags.
*
* @return the metadata header as a byte array, or null if the codec is not supported
* for metadata generation
@@ -361,38 +369,58 @@ public class OggFromWebMWriter implements Closeable {
Log.d(TAG, "Downloading media with codec ID " + webmTrack.codecId);
}
+ final var metadata = new ArrayList>();
+ if (streamInfo != null) {
+ final SongMetadata songMetadata = streamInfo.getSongMetadata();
+ final StreamInfoMetadataHelper metadHelper = new StreamInfoMetadataHelper(streamInfo);
+ // metadata that can be present in the stream info and the song metadata.
+ // Use the song metadata if available, otherwise fallback to stream info.
+ metadata.add(Pair.create("COMMENT", streamInfo.getUrl()));
+ metadata.add(Pair.create("GENRE", metadHelper.getGenre()));
+ metadata.add(Pair.create("ARTIST", metadHelper.getArtist()));
+ metadata.add(Pair.create("TITLE", metadHelper.getTitle()));
+ metadata.add(Pair.create("DATE", metadHelper.getReleaseDate()
+ .getLocalDateTime()
+ .format(DateTimeFormatter.ISO_DATE)));
+ // Additional metadata that is only present in the song metadata
+ if (songMetadata != null) {
+ metadata.add(Pair.create("ALBUM", songMetadata.album));
+ if (songMetadata.track != SongMetadata.TRACK_UNKNOWN) {
+ // TRACKNUMBER is suggested in Vorbis spec,
+ // but TRACK is more commonly used in practice
+ metadata.add(Pair.create("TRACKNUMBER", String.valueOf(songMetadata.track)));
+ metadata.add(Pair.create("TRACK", String.valueOf(songMetadata.track)));
+ }
+ metadata.add(Pair.create("PERFORMER", String.join(", ", songMetadata.performer)));
+ metadata.add(Pair.create("ORGANIZATION", songMetadata.label));
+ metadata.add(Pair.create("COPYRIGHT", songMetadata.copyright));
+ }
+ // Add thumbnail as cover art at the end because it is the largest metadata entry
+ if (thumbnail != null) {
+ metadata.add(makeFlacPictureTag(thumbnail));
+ }
+ }
+
+ if (DEBUG) {
+ Log.d(TAG, "Creating metadata header with this data:");
+ metadata.forEach(p -> Log.d(TAG, p.first + "=" + p.second));
+ }
+
if ("A_OPUS".equals(webmTrack.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)));
- if (thumbnail != null) {
- metadata.add(makeFlacPictureTag(thumbnail));
- }
- }
-
- if (DEBUG) {
- Log.d(TAG, "Creating metadata header with this data:");
- metadata.forEach(p -> Log.d(TAG, p.first + "=" + p.second));
- }
-
- return makeOpusTagsHeader(metadata);
+ // See RFC7845 5.2: https://datatracker.ietf.org/doc/html/rfc7845.html#section-5.2
+ final byte[] identificationHeader = new byte[]{
+ 0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string
+ 0x00, 0x00, 0x00, 0x00, // vendor (aka. Encoder) string of length 0
+ };
+ return makeCommentHeader(metadata, identificationHeader);
} else if ("A_VORBIS".equals(webmTrack.codecId)) {
// See https://xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-610004.2
- // for the Vorbis comment header format
- // TODO: add Vorbis metadata: same as Opus, but with the Vorbis comment header format
- return new byte[]{
+ final byte[] identificationHeader = new byte[]{
0x03, // packet type for Vorbis comment header
0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, // "vorbis" binary string
- 0x00, 0x00, 0x00, 0x00, // writing application string size (not present)
- 0x00, 0x00, 0x00, 0x00 // additional tags count (zero means no tags)
+ 0x00, 0x00, 0x00, 0x00, // vendor (aka. Encoder) string of length 0
};
+ return makeCommentHeader(metadata, identificationHeader);
}
// not implemented for the desired codec
@@ -402,12 +430,12 @@ public class OggFromWebMWriter implements Closeable {
/**
* This creates a single metadata tag for use in opus metadata headers. It contains the four
* byte string length field and includes the string as-is. This cannot be used independently,
- * but must follow a proper "OpusTags" header.
+ * but must follow a proper Comment header.
*
* @param pair A key-value pair in the format "KEY=some value"
* @return The binary data of the encoded metadata tag
*/
- private static byte[] makeOpusMetadataTag(final Pair pair) {
+ private static byte[] makeVorbisMetadataTag(final Pair pair) {
final var keyValue = pair.first.toUpperCase() + "=" + pair.second.trim();
final var bytes = keyValue.getBytes();
@@ -483,24 +511,21 @@ public class OggFromWebMWriter implements Closeable {
}
/**
- * This returns a complete "OpusTags" header, created from the provided metadata tags.
- *
- * You probably want to use makeOpusMetadata(), which uses this function to create
- * a header with sensible metadata filled in.
- *
- * @ImplNote See
- * RFC7845 5.2
+ * This returns a complete Comment header, created from the provided metadata tags.
*
* @param keyValueLines A list of pairs of the tags. This can also be though of as a mapping
* from one key to multiple values.
+ * @param identificationHeader the identification header for the codec,
+ * which is required to be prefixed to the comment header.
* @return The binary header
*/
- private static byte[] makeOpusTagsHeader(final List> keyValueLines) {
+ private static byte[] makeCommentHeader(final List> keyValueLines,
+ final byte[] identificationHeader) {
final var tags = keyValueLines
.stream()
- .filter(p -> !p.second.isBlank())
- .map(OggFromWebMWriter::makeOpusMetadataTag)
- .collect(Collectors.toUnmodifiableList());
+ .filter(p -> p.second != null && !p.second.isBlank())
+ .map(OggFromWebMWriter::makeVorbisMetadataTag)
+ .toList();
final var tagsBytes = tags.stream().collect(Collectors.summingInt(arr -> arr.length));
@@ -509,11 +534,7 @@ public class OggFromWebMWriter implements Closeable {
final var head = ByteBuffer.allocate(byteCount);
head.order(ByteOrder.LITTLE_ENDIAN);
- // See RFC7845 5.2: https://datatracker.ietf.org/doc/html/rfc7845.html#section-5.2
- head.put(new byte[]{
- 0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string
- 0x00, 0x00, 0x00, 0x00, // vendor (aka. Encoder) string of length 0
- });
+ head.put(identificationHeader);
head.putInt(tags.size()); // 4 bytes for tag count
tags.forEach(head::put); // dynamic amount of tag bytes
diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamInfoMetadataHelper.kt b/app/src/main/java/org/schabi/newpipe/util/StreamInfoMetadataHelper.kt
new file mode 100644
index 000000000..3e54ecac2
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/util/StreamInfoMetadataHelper.kt
@@ -0,0 +1,57 @@
+package org.schabi.newpipe.util
+
+import org.schabi.newpipe.extractor.localization.DateWrapper
+import org.schabi.newpipe.extractor.stream.SongMetadata
+import org.schabi.newpipe.extractor.stream.StreamInfo
+
+class StreamInfoMetadataHelper(
+ val streamInfo: StreamInfo
+) {
+ val songInfo: SongMetadata? = streamInfo.songMetadata
+
+ fun getTitle(): String? {
+ if (songInfo?.title?.contentEquals(streamInfo.name) == true) {
+ // YT Music uses uppercase chars in the description, but the StreamInfo name is using
+ // the correct case, so we prefer that
+ return streamInfo.name
+ }
+ return if (songInfo?.title?.isBlank() == false) songInfo.title else streamInfo.name
+ }
+
+ fun getArtist(): String? {
+ if (songInfo?.artist?.contentEquals(streamInfo.uploaderName) == true) {
+ // YT Music uses uppercase chars in the description, but the uploader name is using
+ // the correct case, so we prefer the uploader name
+ return streamInfo.uploaderName
+ }
+ return if (songInfo?.artist?.isBlank() == false) {
+ songInfo.artist
+ } else {
+ streamInfo.uploaderName
+ }
+ }
+
+ fun getPerformer(): List = songInfo?.performer ?: emptyList()
+
+ fun getComposer(): String? = songInfo?.composer
+
+ fun getGenre(): String? = if (songInfo?.genre?.isEmpty() == false) {
+ songInfo.genre
+ } else {
+ streamInfo.category
+ }
+
+ fun getAlbum(): String? = songInfo?.album
+
+ fun getTrackNumber(): Int? = if (songInfo?.track != SongMetadata.TRACK_UNKNOWN) songInfo?.track else null
+
+ fun getDuration(): Long = songInfo?.duration?.seconds ?: streamInfo.duration
+
+ fun getReleaseDate(): DateWrapper = songInfo?.releaseDate ?: streamInfo.uploadDate
+
+ fun getRecordLabel(): String? = songInfo?.label
+
+ fun getCopyright(): String? = songInfo?.copyright ?: streamInfo.licence
+
+ fun getLocation(): String? = songInfo?.location
+}