Skip to content

Commit 51741e3

Browse files
committed
Add more metadata fields to OGG and MP4 downloads
Copyright, record label, album, label, ...
1 parent 67c9ca0 commit 51741e3

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
@@ -352,8 +360,8 @@ private int makePacketHeader(final long granPos, @NonNull final ByteBuffer buffe
352360
* @see <a href="https://xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-610004.2">
353361
* Vorbis I 4.2. Header decode and decode setup</a> and
354362
* <a href="https://xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-820005">
355-
* Vorbis 5. comment field and header specification</a>
356-
* for VORBIS metadata header format
363+
* Vorbis I 5. comment field and header specification</a>
364+
* for VORBIS metadata header format. Vorbis I 5. lists all the possible metadata tags.
357365
*
358366
* @return the metadata header as a byte array, or null if the codec is not supported
359367
* for metadata generation
@@ -364,38 +372,58 @@ private byte[] makeCommentHeader() {
364372
Log.d(TAG, "Downloading media with codec ID " + webmTrack.codecId);
365373
}
366374

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

388-
return makeOpusTagsHeader(metadata);
412+
if ("A_OPUS".equals(webmTrack.codecId)) {
413+
// See RFC7845 5.2: https://datatracker.ietf.org/doc/html/rfc7845.html#section-5.2
414+
final byte[] identificationHeader = new byte[]{
415+
0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string
416+
0x00, 0x00, 0x00, 0x00, // vendor (aka. Encoder) string of length 0
417+
};
418+
return makeCommentHeader(metadata, identificationHeader);
389419
} else if ("A_VORBIS".equals(webmTrack.codecId)) {
390420
// See https://xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-610004.2
391-
// for the Vorbis comment header format
392-
// TODO: add Vorbis metadata: same as Opus, but with the Vorbis comment header format
393-
return new byte[]{
421+
final byte[] identificationHeader = new byte[]{
394422
0x03, // packet type for Vorbis comment header
395423
0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, // "vorbis" binary string
396-
0x00, 0x00, 0x00, 0x00, // writing application string size (not present)
397-
0x00, 0x00, 0x00, 0x00 // additional tags count (zero means no tags)
424+
0x00, 0x00, 0x00, 0x00, // vendor (aka. Encoder) string of length 0
398425
};
426+
return makeCommentHeader(metadata, identificationHeader);
399427
}
400428

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

416444
final var bytes = keyValue.getBytes();
@@ -486,24 +514,21 @@ private static Pair<String, String> makeFlacPictureTag(final Bitmap bitmap) {
486514
}
487515

488516
/**
489-
* This returns a complete "OpusTags" header, created from the provided metadata tags.
490-
* <p>
491-
* You probably want to use makeOpusMetadata(), which uses this function to create
492-
* a header with sensible metadata filled in.
493-
*
494-
* @ImplNote See <a href="https://datatracker.ietf.org/doc/html/rfc7845.html#section-5.2">
495-
* RFC7845 5.2</a>
517+
* This returns a complete Comment header, created from the provided metadata tags.
496518
*
497519
* @param keyValueLines A list of pairs of the tags. This can also be though of as a mapping
498520
* from one key to multiple values.
521+
* @param identificationHeader the identification header for the codec,
522+
* which is required to be prefixed to the comment header.
499523
* @return The binary header
500524
*/
501-
private static byte[] makeOpusTagsHeader(final List<Pair<String, String>> keyValueLines) {
525+
private static byte[] makeCommentHeader(final List<Pair<String, String>> keyValueLines,
526+
final byte[] identificationHeader) {
502527
final var tags = keyValueLines
503528
.stream()
504-
.filter(p -> !p.second.isBlank())
505-
.map(OggFromWebMWriter::makeOpusMetadataTag)
506-
.collect(Collectors.toUnmodifiableList());
529+
.filter(p -> p.second != null && !p.second.isBlank())
530+
.map(OggFromWebMWriter::makeVorbisMetadataTag)
531+
.toList();
507532

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

@@ -512,11 +537,7 @@ private static byte[] makeOpusTagsHeader(final List<Pair<String, String>> keyVal
512537

513538
final var head = ByteBuffer.allocate(byteCount);
514539
head.order(ByteOrder.LITTLE_ENDIAN);
515-
// See RFC7845 5.2: https://datatracker.ietf.org/doc/html/rfc7845.html#section-5.2
516-
head.put(new byte[]{
517-
0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string
518-
0x00, 0x00, 0x00, 0x00, // vendor (aka. Encoder) string of length 0
519-
});
540+
head.put(identificationHeader);
520541
head.putInt(tags.size()); // 4 bytes for tag count
521542
tags.forEach(head::put); // dynamic amount of tag bytes
522543

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
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? = songInfo?.track
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)