Skip to content

Commit a811493

Browse files
committed
Add more metadata fields to OGG and MP4 downloads
Copyright, record label, album, label, ...
1 parent 454bc97 commit a811493

File tree

3 files changed

+148
-51
lines changed

3 files changed

+148
-51
lines changed

app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import org.schabi.newpipe.streams.Mp4DashReader.TrackKind;
1212
import org.schabi.newpipe.streams.Mp4DashReader.TrunEntry;
1313
import org.schabi.newpipe.streams.io.SharpStream;
14+
import org.schabi.newpipe.util.StreamInfoMetadataHelper;
1415

1516
import java.io.ByteArrayOutputStream;
1617
import java.io.IOException;
@@ -930,17 +931,21 @@ private byte[] makeSgpd() {
930931

931932
/**
932933
* Create the 'udta' box with metadata fields.
934+
* {@code udta} is a user data box that can contain various types of metadata,
935+
* including title, artist, date, and cover art.
936+
* @see <a href="https://developer.apple.com/documentation/quicktime-file-format/
937+
* user_data_atoms">Apple Quick Time Format Specification for user data atoms</a>
938+
* @see <a href="https://wiki.multimedia.cx/index.php?title=FFmpeg_Metadata
939+
* #QuickTime/MOV/MP4/M4A/et_al.">Multimedia Wiki FFmpeg Metadata</a>
940+
* @see <a href="https://atomicparsley.sourceforge.net/mpeg-4files.html">atomicparsley docs</a>
941+
* for a short and understandable reference about metadata keys and values
933942
* @throws IOException
934943
*/
935944
private void makeUdta() throws IOException {
936945
if (streamInfo == null) {
937946
return;
938947
}
939948

940-
final String title = streamInfo.getName();
941-
final String artist = streamInfo.getUploaderName();
942-
final String date = streamInfo.getUploadDate().getLocalDateTime().toLocalDate().toString();
943-
944949
// udta
945950
final int startUdta = auxOffset();
946951
auxWrite(ByteBuffer.allocate(8).putInt(0).putInt(0x75647461).array()); // "udta"
@@ -957,6 +962,16 @@ private void makeUdta() throws IOException {
957962
final int startIlst = auxOffset();
958963
auxWrite(ByteBuffer.allocate(8).putInt(0).putInt(0x696C7374).array()); // "ilst"
959964

965+
// write metadata items
966+
967+
final var metaHelper = new StreamInfoMetadataHelper(streamInfo);
968+
final String title = metaHelper.getTitle();
969+
final String artist = metaHelper.getArtist();
970+
final String date = metaHelper.getReleaseDate().getLocalDateTime()
971+
.toLocalDate().toString();
972+
final String recordLabel = metaHelper.getRecordLabel();
973+
final String copyright = metaHelper.getCopyright();
974+
960975
if (title != null && !title.isEmpty()) {
961976
writeMetaItem("©nam", title);
962977
}
@@ -967,8 +982,12 @@ private void makeUdta() throws IOException {
967982
// this means 'year' in mp4 metadata, who the hell thought that?
968983
writeMetaItem("©day", date);
969984
}
970-
971-
985+
if (recordLabel != null && !recordLabel.isEmpty()) {
986+
writeMetaItem("©lab", recordLabel);
987+
}
988+
if (copyright != null && !copyright.isEmpty()) {
989+
writeMetaItem("©cpy", copyright);
990+
}
972991

973992
if (thumbnail != null) {
974993
final ByteArrayOutputStream baos = new ByteArrayOutputStream();

app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java

Lines changed: 66 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
import androidx.annotation.NonNull;
1010
import androidx.annotation.Nullable;
1111

12+
import org.schabi.newpipe.extractor.stream.SongMetadata;
1213
import org.schabi.newpipe.extractor.stream.StreamInfo;
1314
import org.schabi.newpipe.streams.WebMReader.Cluster;
1415
import org.schabi.newpipe.streams.WebMReader.Segment;
1516
import org.schabi.newpipe.streams.WebMReader.SimpleBlock;
1617
import org.schabi.newpipe.streams.WebMReader.WebMTrack;
1718
import org.schabi.newpipe.streams.io.SharpStream;
19+
import org.schabi.newpipe.util.StreamInfoMetadataHelper;
1820

1921
import java.io.ByteArrayOutputStream;
2022
import java.io.Closeable;
@@ -40,12 +42,18 @@
4042
* </p>
4143
* <ul>
4244
* <li>FLAC: <a href="https://www.rfc-editor.org/rfc/rfc9639">RFC 9639</a></li>
45+
* <li>
46+
* Vorbis: <a href="https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html">Vorbis I</a>.
47+
* <br>
48+
* Vorbis uses FLAC picture blocks for embedding cover art in the metadata.
49+
* </li>
4350
* <li>Opus: All specs can be found at <a href="https://opus-codec.org/docs/">
4451
* https://opus-codec.org/docs/</a>.
4552
* <a href="https://datatracker.ietf.org/doc/html/rfc7845.html">RFC7845</a>
4653
* defines the Ogg encapsulation for Opus streams, i.e.the container format and metadata.
54+
* <br>
55+
* Opus uses multiple Vorbis I features, e.g. the comment header format for metadata.
4756
* </li>
48-
* <li>Vorbis: <a href="https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html">Vorbis I</a></li>
4957
* </ul>
5058
*
5159
* @author kapodamy
@@ -349,8 +357,8 @@ private int makePacketHeader(final long granPos, @NonNull final ByteBuffer buffe
349357
* @see <a href="https://xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-610004.2">
350358
* Vorbis I 4.2. Header decode and decode setup</a> and
351359
* <a href="https://xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-820005">
352-
* Vorbis 5. comment field and header specification</a>
353-
* for VORBIS metadata header format
360+
* Vorbis I 5. comment field and header specification</a>
361+
* for VORBIS metadata header format. Vorbis I 5. lists all the possible metadata tags.
354362
*
355363
* @return the metadata header as a byte array, or null if the codec is not supported
356364
* for metadata generation
@@ -361,38 +369,58 @@ private byte[] makeCommentHeader() {
361369
Log.d(TAG, "Downloading media with codec ID " + webmTrack.codecId);
362370
}
363371

364-
if ("A_OPUS".equals(webmTrack.codecId)) {
365-
final var metadata = new ArrayList<Pair<String, String>>();
366-
if (streamInfo != null) {
367-
metadata.add(Pair.create("COMMENT", streamInfo.getUrl()));
368-
metadata.add(Pair.create("GENRE", streamInfo.getCategory()));
369-
metadata.add(Pair.create("ARTIST", streamInfo.getUploaderName()));
370-
metadata.add(Pair.create("TITLE", streamInfo.getName()));
371-
metadata.add(Pair.create("DATE", streamInfo
372-
.getUploadDate()
373-
.getLocalDateTime()
374-
.format(DateTimeFormatter.ISO_DATE)));
375-
if (thumbnail != null) {
376-
metadata.add(makeFlacPictureTag(thumbnail));
377-
}
372+
final var metadata = new ArrayList<Pair<String, String>>();
373+
if (streamInfo != null) {
374+
final SongMetadata songMetadata = streamInfo.getSongMetadata();
375+
final StreamInfoMetadataHelper metadHelper = new StreamInfoMetadataHelper(streamInfo);
376+
// metadata that can be present in the stream info and the song metadata.
377+
// Use the song metadata if available, otherwise fallback to stream info.
378+
metadata.add(Pair.create("COMMENT", streamInfo.getUrl()));
379+
metadata.add(Pair.create("GENRE", metadHelper.getGenre()));
380+
metadata.add(Pair.create("ARTIST", metadHelper.getArtist()));
381+
metadata.add(Pair.create("TITLE", metadHelper.getTitle()));
382+
metadata.add(Pair.create("DATE", metadHelper.getReleaseDate()
383+
.getLocalDateTime()
384+
.format(DateTimeFormatter.ISO_DATE)));
385+
// Additional metadata that is only present in the song metadata
386+
if (songMetadata != null) {
387+
metadata.add(Pair.create("ALBUM", songMetadata.album));
388+
if (songMetadata.track != SongMetadata.TRACK_UNKNOWN) {
389+
// TRACKNUMBER is suggested in Vorbis spec,
390+
// but TRACK is more commonly used in practice
391+
metadata.add(Pair.create("TRACKNUMBER", String.valueOf(songMetadata.track)));
392+
metadata.add(Pair.create("TRACK", String.valueOf(songMetadata.track)));
393+
}
394+
metadata.add(Pair.create("PERFORMER", String.join(", ", songMetadata.performer)));
395+
metadata.add(Pair.create("ORGANIZATION", songMetadata.label));
396+
metadata.add(Pair.create("COPYRIGHT", songMetadata.copyright));
378397
}
379-
380-
if (DEBUG) {
381-
Log.d(TAG, "Creating metadata header with this data:");
382-
metadata.forEach(p -> Log.d(TAG, p.first + "=" + p.second));
398+
// Add thumbnail as cover art at the end because it is the largest metadata entry
399+
if (thumbnail != null) {
400+
metadata.add(makeFlacPictureTag(thumbnail));
383401
}
402+
}
403+
404+
if (DEBUG) {
405+
Log.d(TAG, "Creating metadata header with this data:");
406+
metadata.forEach(p -> Log.d(TAG, p.first + "=" + p.second));
407+
}
384408

385-
return makeOpusTagsHeader(metadata);
409+
if ("A_OPUS".equals(webmTrack.codecId)) {
410+
// See RFC7845 5.2: https://datatracker.ietf.org/doc/html/rfc7845.html#section-5.2
411+
final byte[] identificationHeader = new byte[]{
412+
0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string
413+
0x00, 0x00, 0x00, 0x00, // vendor (aka. Encoder) string of length 0
414+
};
415+
return makeCommentHeader(metadata, identificationHeader);
386416
} else if ("A_VORBIS".equals(webmTrack.codecId)) {
387417
// See https://xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-610004.2
388-
// for the Vorbis comment header format
389-
// TODO: add Vorbis metadata: same as Opus, but with the Vorbis comment header format
390-
return new byte[]{
418+
final byte[] identificationHeader = new byte[]{
391419
0x03, // packet type for Vorbis comment header
392420
0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, // "vorbis" binary string
393-
0x00, 0x00, 0x00, 0x00, // writing application string size (not present)
394-
0x00, 0x00, 0x00, 0x00 // additional tags count (zero means no tags)
421+
0x00, 0x00, 0x00, 0x00, // vendor (aka. Encoder) string of length 0
395422
};
423+
return makeCommentHeader(metadata, identificationHeader);
396424
}
397425

398426
// not implemented for the desired codec
@@ -402,12 +430,12 @@ private byte[] makeCommentHeader() {
402430
/**
403431
* This creates a single metadata tag for use in opus metadata headers. It contains the four
404432
* byte string length field and includes the string as-is. This cannot be used independently,
405-
* but must follow a proper "OpusTags" header.
433+
* but must follow a proper Comment header.
406434
*
407435
* @param pair A key-value pair in the format "KEY=some value"
408436
* @return The binary data of the encoded metadata tag
409437
*/
410-
private static byte[] makeOpusMetadataTag(final Pair<String, String> pair) {
438+
private static byte[] makeVorbisMetadataTag(final Pair<String, String> pair) {
411439
final var keyValue = pair.first.toUpperCase() + "=" + pair.second.trim();
412440

413441
final var bytes = keyValue.getBytes();
@@ -483,24 +511,21 @@ private static Pair<String, String> makeFlacPictureTag(final Bitmap bitmap) {
483511
}
484512

485513
/**
486-
* This returns a complete "OpusTags" header, created from the provided metadata tags.
487-
* <p>
488-
* You probably want to use makeOpusMetadata(), which uses this function to create
489-
* a header with sensible metadata filled in.
490-
*
491-
* @ImplNote See <a href="https://datatracker.ietf.org/doc/html/rfc7845.html#section-5.2">
492-
* RFC7845 5.2</a>
514+
* This returns a complete Comment header, created from the provided metadata tags.
493515
*
494516
* @param keyValueLines A list of pairs of the tags. This can also be though of as a mapping
495517
* from one key to multiple values.
518+
* @param identificationHeader the identification header for the codec,
519+
* which is required to be prefixed to the comment header.
496520
* @return The binary header
497521
*/
498-
private static byte[] makeOpusTagsHeader(final List<Pair<String, String>> keyValueLines) {
522+
private static byte[] makeCommentHeader(final List<Pair<String, String>> keyValueLines,
523+
final byte[] identificationHeader) {
499524
final var tags = keyValueLines
500525
.stream()
501-
.filter(p -> !p.second.isBlank())
502-
.map(OggFromWebMWriter::makeOpusMetadataTag)
503-
.collect(Collectors.toUnmodifiableList());
526+
.filter(p -> p.second != null && !p.second.isBlank())
527+
.map(OggFromWebMWriter::makeVorbisMetadataTag)
528+
.toList();
504529

505530
final var tagsBytes = tags.stream().collect(Collectors.summingInt(arr -> arr.length));
506531

@@ -509,11 +534,7 @@ private static byte[] makeOpusTagsHeader(final List<Pair<String, String>> keyVal
509534

510535
final var head = ByteBuffer.allocate(byteCount);
511536
head.order(ByteOrder.LITTLE_ENDIAN);
512-
// See RFC7845 5.2: https://datatracker.ietf.org/doc/html/rfc7845.html#section-5.2
513-
head.put(new byte[]{
514-
0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string
515-
0x00, 0x00, 0x00, 0x00, // vendor (aka. Encoder) string of length 0
516-
});
537+
head.put(identificationHeader);
517538
head.putInt(tags.size()); // 4 bytes for tag count
518539
tags.forEach(head::put); // dynamic amount of tag bytes
519540

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package org.schabi.newpipe.util
2+
3+
import org.schabi.newpipe.extractor.localization.DateWrapper
4+
import org.schabi.newpipe.extractor.stream.SongMetadata
5+
import org.schabi.newpipe.extractor.stream.StreamInfo
6+
7+
class StreamInfoMetadataHelper(
8+
val streamInfo: StreamInfo
9+
) {
10+
val songInfo: SongMetadata? = streamInfo.songMetadata
11+
12+
fun getTitle(): String? {
13+
if (songInfo?.title?.contentEquals(streamInfo.name) == true) {
14+
// YT Music uses uppercase chars in the description, but the StreamInfo name is using
15+
// the correct case, so we prefer that
16+
return streamInfo.name
17+
}
18+
return if (songInfo?.title?.isBlank() == false) songInfo.title else streamInfo.name
19+
}
20+
21+
fun getArtist(): String? {
22+
if (songInfo?.artist?.contentEquals(streamInfo.uploaderName) == true) {
23+
// YT Music uses uppercase chars in the description, but the uploader name is using
24+
// the correct case, so we prefer the uploader name
25+
return streamInfo.uploaderName
26+
}
27+
return if (songInfo?.artist?.isBlank() == false) {
28+
songInfo.artist
29+
} else {
30+
streamInfo.uploaderName
31+
}
32+
}
33+
34+
fun getPerformer(): List<String?> = songInfo?.performer ?: emptyList()
35+
36+
fun getComposer(): String? = songInfo?.composer
37+
38+
fun getGenre(): String? = if (songInfo?.genre?.isEmpty() == false) {
39+
songInfo.genre
40+
} else {
41+
streamInfo.category
42+
}
43+
44+
fun getAlbum(): String? = songInfo?.album
45+
46+
fun getTrackNumber(): Int? = if (songInfo?.track != SongMetadata.TRACK_UNKNOWN) songInfo?.track else null
47+
48+
fun getDuration(): Long = songInfo?.duration?.seconds ?: streamInfo.duration
49+
50+
fun getReleaseDate(): DateWrapper = songInfo?.releaseDate ?: streamInfo.uploadDate
51+
52+
fun getRecordLabel(): String? = songInfo?.label
53+
54+
fun getCopyright(): String? = songInfo?.copyright ?: streamInfo.licence
55+
56+
fun getLocation(): String? = songInfo?.location
57+
}

0 commit comments

Comments
 (0)