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; *

* * * @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 +}