diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
index e5153856e..57b25005e 100644
--- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
@@ -1062,11 +1062,13 @@ public class DownloadDialog extends DialogFragment
kind = 'a';
selectedStream = audioStreamsAdapter.getItem(selectedAudioIndex);
- if (selectedStream.getFormat() == MediaFormat.M4A) {
- psName = Postprocessing.ALGORITHM_M4A_NO_DASH;
- } else if (selectedStream.getFormat() == MediaFormat.WEBMA_OPUS) {
- psName = Postprocessing.ALGORITHM_OGG_FROM_WEBM_DEMUXER;
- }
+ psName = switch (selectedStream.getFormat()) {
+ case M4A -> Postprocessing.ALGORITHM_M4A_NO_DASH;
+ case WEBMA_OPUS -> Postprocessing.ALGORITHM_OGG_FROM_WEBM_DEMUXER;
+ case MP3 -> Postprocessing.ALGORITHM_MP3_METADATA;
+ default -> null;
+ };
+
break;
case R.id.video_button:
kind = 'v';
diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp3Metadata.java b/app/src/main/java/us/shandian/giga/postprocessing/Mp3Metadata.java
new file mode 100644
index 000000000..a839de582
--- /dev/null
+++ b/app/src/main/java/us/shandian/giga/postprocessing/Mp3Metadata.java
@@ -0,0 +1,321 @@
+package us.shandian.giga.postprocessing;
+
+import static java.time.ZoneOffset.UTC;
+
+import android.graphics.Bitmap;
+
+import org.schabi.newpipe.streams.io.SharpInputStream;
+import org.schabi.newpipe.streams.io.SharpStream;
+import org.schabi.newpipe.util.StreamInfoMetadataHelper;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.PushbackInputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+
+/**
+ * Adds Metadata tp to an MP3 file by writing ID3v2.4 frames, i.e. metadata tags,
+ * at the start of the file.
+ * @see ID3v2.4 specification
+ * @see ID3v2.4 frames
+ */
+public class Mp3Metadata extends Postprocessing {
+
+ Mp3Metadata() {
+ super(true, true, ALGORITHM_MP3_METADATA);
+ }
+
+ @Override
+ int process(SharpStream out, SharpStream... sources) throws IOException {
+ if (sources == null || sources.length == 0 || sources[0] == null) {
+ // nothing to do
+ return OK_RESULT;
+ }
+
+ // MP3 metadata is stored in ID3v2 tags at the start of the file,
+ // so we need to build the tag in memory first and then write it
+ // before copying the rest of the file.
+
+ final ByteArrayOutputStream frames = new ByteArrayOutputStream();
+ final FrameWriter fw = new FrameWriter(frames);
+
+ makeMetadata(fw);
+ makePictureFrame(fw);
+
+ byte[] framesBytes = frames.toByteArray();
+
+
+ // ID3 header: 'ID3' + ver(0x04,0x00) + flags(0) + size (synchsafe 4 bytes)
+ final ByteArrayOutputStream tag = new ByteArrayOutputStream();
+ tag.write(new byte[]{'I', 'D', '3'});
+ tag.write(0x04); // version 2.4
+ tag.write(0x00); // revision
+ tag.write(0x00); // flags
+ int tagSize = framesBytes.length; // size excluding 10-byte header
+ tag.write(toSynchsafe(tagSize));
+ tag.write(framesBytes);
+
+
+ byte[] tagBytes = tag.toByteArray();
+ out.write(tagBytes);
+ try (InputStream sIn = new SharpInputStream(sources[0])) {
+ copyStreamSkippingId3(sIn, out);
+ }
+ out.flush();
+
+ return OK_RESULT;
+
+ }
+
+ /**
+ * Write metadata frames based on the StreamInfo's metadata.
+ * @see ID3v2.4 frames for a list of frame types
+ * and their identifiers.
+ * @param fw the FrameWriter to write frames to
+ * @throws IOException if an I/O error occurs while writing frames
+ */
+ private void makeMetadata(FrameWriter fw) throws IOException {
+ var metadata = new StreamInfoMetadataHelper(this.streamInfo);
+
+ fw.writeTextFrame("TIT2", metadata.getTitle());
+ fw.writeTextFrame("TPE1", metadata.getArtist());
+ fw.writeTextFrame("TCOM", metadata.getComposer());
+ fw.writeTextFrame("TIPL", metadata.getPerformer());
+ fw.writeTextFrame("TCON", metadata.getGenre());
+ fw.writeTextFrame("TALB", metadata.getAlbum());
+
+ final LocalDateTime releaseDate = metadata.getReleaseDate().getLocalDateTime(UTC);
+ // determine precision by checking that lower-order fields are at their "zero"/start values
+ boolean isOnlyMonth = releaseDate.getDayOfMonth() == 1
+ && releaseDate.getHour() == 0
+ && releaseDate.getMinute() == 0
+ && releaseDate.getSecond() == 0
+ && releaseDate.getNano() == 0;
+ boolean isOnlyYear = releaseDate.getMonthValue() == 1
+ && isOnlyMonth;
+ // see https://id3.org/id3v2.4.0-structure > 4. ID3v2 frame overview
+ // for date formats in TDRC frame
+ final String datePattern;
+ if (isOnlyYear) {
+ datePattern = "yyyy";
+ } else if (isOnlyMonth) {
+ datePattern = "yyyy-MM";
+ } else {
+ datePattern = "yyyy-MM-dd";
+ }
+ fw.writeTextFrame("TDRC",
+ releaseDate.format(DateTimeFormatter.ofPattern(datePattern)));
+
+
+ if (metadata.getTrackNumber() != null) {
+ fw.writeTextFrame("TRCK", String.valueOf(metadata.getTrackNumber()));
+ }
+
+ fw.writeTextFrame("TPUB", metadata.getRecordLabel());
+ fw.writeTextFrame("TCOP", metadata.getCopyright());
+
+ // WXXX is a user defined URL link frame, we can use it to store the URL of the stream
+ // However, since it's user defined, so not all players support it.
+ // Using the comment frame (COMM) as fallback
+ fw.writeTextFrame("WXXX", streamInfo.getUrl());
+ fw.writeCommentFrame("eng", streamInfo.getUrl());
+ }
+
+ /**
+ * Write a picture frame (APIC) with the thumbnail image if available.
+ * @param fw the FrameWriter to write the picture frame to
+ * @throws IOException if an I/O error occurs while writing the frame
+ */
+ private void makePictureFrame(FrameWriter fw) throws IOException {
+ if (thumbnail != null) {
+ final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ thumbnail.compress(Bitmap.CompressFormat.PNG, 100, baos);
+ final byte[] imgBytes = baos.toByteArray();
+ baos.close();
+ fw.writePictureFrame("image/png", imgBytes);
+ }
+ }
+
+ /**
+ * Copy the input stream to the output stream, but if the input stream starts with an ID3v2 tag,
+ * skip the tag and only copy the audio data.
+ * @param in the input stream to read from (should be at the start of the MP3 file)
+ * @param out the output stream to write to
+ * @throws IOException if an I/O error occurs while reading or writing
+ */
+ private static void copyStreamSkippingId3(InputStream in, SharpStream out) throws IOException {
+ PushbackInputStream pin = (in instanceof PushbackInputStream) ? (PushbackInputStream) in : new PushbackInputStream(in, 10);
+ byte[] header = new byte[10];
+ int hr = pin.read(header);
+ if (hr == 10 && header[0] == 'I' && header[1] == 'D' && header[2] == '3') {
+ // bytes 3 and 4 are version and revision and byte 5 is flags
+ // the size is stored as synchsafe at bytes 6..9
+ int size = fromSynchsafe(header, 6);
+ long remaining = size;
+ // consume exactly 'size' bytes, i.e. the rest of the metadata frames, from the stream
+ byte[] skipBuf = new byte[8192];
+ while (remaining > 0) {
+ int toRead = (int) Math.min(skipBuf.length, remaining);
+ int r = pin.read(skipBuf, 0, toRead);
+ if (r <= 0) break;
+ remaining -= r;
+ }
+ } else {
+ // push header bytes back so copy will include them
+ if (hr > 0) pin.unread(header, 0, hr);
+ }
+
+ // copy rest
+ byte[] buf = new byte[8192];
+ int r;
+ while ((r = pin.read(buf)) > 0) out.write(buf, 0, r);
+ }
+
+ /**
+ * Create a 4-byte synchsafe integer from a regular integer value.
+ * @see ID3v2.4 specification section
+ * 6.2. Synchsafe integers
+ * @param value the integer value to convert (should be non-negative and less than 2^28)
+ * @return the synchsafe byte array
+ */
+ private static byte[] toSynchsafe(int value) {
+ byte[] b = new byte[4];
+ b[0] = (byte) ((value >> 21) & 0x7F);
+ b[1] = (byte) ((value >> 14) & 0x7F);
+ b[2] = (byte) ((value >> 7) & 0x7F);
+ b[3] = (byte) (value & 0x7F);
+ return b;
+ }
+
+ /**
+ * Get a regular integer from a 4-byte synchsafe byte array.
+ * @see ID3v2.4 specification section
+ * 6.2. Synchsafe integers
+ * @param b the byte array containing the synchsafe integer
+ * (should be at least 4 bytes + offset long)
+ * @param offset the offset in the byte array where the synchsafe integer starts
+ * @return the regular integer value
+ */
+ private static int fromSynchsafe(byte[] b, int offset) {
+ return ((b[offset] & 0x7F) << 21)
+ | ((b[offset + 1] & 0x7F) << 14)
+ | ((b[offset + 2] & 0x7F) << 7)
+ | (b[offset + 3] & 0x7F);
+ }
+
+
+ /**
+ * Helper class to write ID3v2.4 frames to a ByteArrayOutputStream.
+ */
+ private static class FrameWriter {
+
+ /**
+ * This separator is used to separate multiple entries in a list of an ID3v2 text frame.
+ * @see ID3v2.4 frames section
+ * 4.2. Text information frames
+ */
+ private static final Character TEXT_LIST_SEPARATOR = 0x00;
+ private static final byte UTF8_ENCODING_BYTE = 0x03;
+
+ private final ByteArrayOutputStream out;
+
+ FrameWriter(ByteArrayOutputStream out) {
+ this.out = out;
+ }
+
+ /**
+ * Write a text frame with the given identifier and text content.
+ * @param id the 4 character long frame identifier
+ * @param text the text content to write. If null or blank, no frame is written.
+ * @throws IOException if an I/O error occurs while writing the frame
+ */
+ void writeTextFrame(String id, String text) throws IOException {
+ if (text == null || text.isBlank()) return;
+ byte[] data = text.getBytes(StandardCharsets.UTF_8);
+ ByteArrayOutputStream frame = new ByteArrayOutputStream();
+ frame.write(UTF8_ENCODING_BYTE);
+ frame.write(data);
+ writeFrame(id, frame.toByteArray());
+ }
+
+ /**
+ * Write a text frame that can contain multiple entries separated by the
+ * {@link #TEXT_LIST_SEPARATOR}.
+ * @param id the 4 character long frame identifier
+ * @param texts the list of text entries to write. If null or empty, no frame is written.
+ * Blank or null entries are skipped.
+ * @throws IOException if an I/O error occurs while writing the frame
+ */
+ void writeTextFrame(String id, List texts) throws IOException {
+ if (texts == null || texts.isEmpty()) return;
+ ByteArrayOutputStream frame = new ByteArrayOutputStream();
+ frame.write(UTF8_ENCODING_BYTE);
+ for (int i = 0; i < texts.size(); i++) {
+ String text = texts.get(i);
+ if (text != null && !text.isBlank()) {
+ byte[] data = text.getBytes(StandardCharsets.UTF_8);
+ frame.write(data);
+ if (i < texts.size() - 1) {
+ frame.write(TEXT_LIST_SEPARATOR);
+ }
+ }
+ }
+ writeFrame(id, frame.toByteArray());
+ }
+
+ /**
+ * Write a picture frame (APIC) with the given MIME type and image data.
+ * @see ID3v2.4 frames section
+ * 4.14. Attached picture
+ * @param mimeType the MIME type of the image (e.g. "image/png" or "image/jpeg").
+ * @param imageData the binary data of the image. If empty, no frame is written.
+ * @throws IOException
+ */
+ void writePictureFrame(@Nonnull String mimeType, @Nonnull byte[] imageData)
+ throws IOException {
+ if (imageData.length == 0) return;
+ ByteArrayOutputStream frame = new ByteArrayOutputStream();
+ frame.write(UTF8_ENCODING_BYTE);
+ frame.write(mimeType.getBytes(StandardCharsets.US_ASCII));
+ frame.write(0x00);
+ frame.write(0x03); // picture type: 3 = cover(front)
+ frame.write(0x00); // empty description terminator (UTF-8 empty string)
+ // Then the picture bytes
+ frame.write(imageData);
+ writeFrame("APIC", frame.toByteArray());
+ }
+
+ /**
+ * Write a comment frame (COMM) with the given language and comment text.
+ * @param lang a 3-character ISO-639-2 language code (e.g. "eng" for English).
+ * If null or invalid, defaults to "eng".
+ * @param comment the comment text to write. If null, no frame is written.
+ * @throws IOException
+ */
+ void writeCommentFrame(String lang, String comment) throws IOException {
+ if (comment == null) return;
+ if (lang == null || lang.length() != 3) lang = "eng";
+ ByteArrayOutputStream frame = new ByteArrayOutputStream();
+ frame.write(UTF8_ENCODING_BYTE);
+ frame.write(lang.getBytes(StandardCharsets.US_ASCII));
+ frame.write(0x00); // short content descriptor (empty) terminator
+ frame.write(comment.getBytes(StandardCharsets.UTF_8));
+ writeFrame("COMM", frame.toByteArray());
+ }
+
+ private void writeFrame(String id, byte[] data) throws IOException {
+ if (data == null || data.length == 0) return;
+ // frame header: id(4) size(4 synchsafe) flags(2)
+ out.write(id.getBytes(StandardCharsets.US_ASCII));
+ out.write(toSynchsafe(data.length));
+ out.write(new byte[]{0x00, 0x00});
+ out.write(data);
+ }
+ }
+}
diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java
index 1398e0d01..3cb8b530c 100644
--- a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java
+++ b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java
@@ -29,6 +29,7 @@ public abstract class Postprocessing implements Serializable {
public static final String ALGORITHM_TTML_CONVERTER = "ttml";
public static final String ALGORITHM_WEBM_MUXER = "webm";
+ public static final String ALGORITHM_MP3_METADATA = "mp3-metadata";
public static final String ALGORITHM_MP4_METADATA = "mp4-metadata";
public static final String ALGORITHM_MP4_FROM_DASH_MUXER = "mp4D-mp4";
public static final String ALGORITHM_M4A_NO_DASH = "mp4D-m4a";
@@ -45,6 +46,9 @@ public abstract class Postprocessing implements Serializable {
case ALGORITHM_WEBM_MUXER:
instance = new WebMMuxer();
break;
+ case ALGORITHM_MP3_METADATA:
+ instance = new Mp3Metadata();
+ break;
case ALGORITHM_MP4_METADATA:
instance = new Mp4Metadata();
break;