From 41c6bb0face868d75c9924dee4162110362183c1 Mon Sep 17 00:00:00 2001 From: tobigr Date: Mon, 9 Feb 2026 22:14:44 +0100 Subject: [PATCH] Add metadata and cover art to mp3 files --- .../newpipe/download/DownloadDialog.java | 12 +- .../giga/postprocessing/Mp3Metadata.java | 321 ++++++++++++++++++ .../giga/postprocessing/Postprocessing.java | 4 + 3 files changed, 332 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/Mp3Metadata.java 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;