Add metadata and cover art to mp3 files

This commit is contained in:
tobigr 2026-02-09 22:14:44 +01:00
parent ae72d2f63d
commit 41c6bb0fac
3 changed files with 332 additions and 5 deletions

View File

@ -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';

View File

@ -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 <a href="https://id3.org/id3v2.4.0-structure">ID3v2.4 specification</a>
* @see <a href="https://id3.org/id3v2.4.0-frames">ID3v2.4 frames</a>
*/
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 <a href="https://id3.org/id3v2.4.0-frames">ID3v2.4 frames</a> 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 <a href="https://id3.org/id3v2.4.0-structure">ID3v2.4 specification</a> section
* <i>6.2. Synchsafe integers</i>
* @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 <a href="https://id3.org/id3v2.4.0-structure">ID3v2.4 specification</a> section
* <i>6.2. Synchsafe integers</i>
* @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 <a href="https://id3.org/id3v2.4.0-frames">ID3v2.4 frames</a> section
* <i>4.2. Text information frames</i>
*/
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<String> 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 <a href="https://id3.org/id3v2.4.0-frames">ID3v2.4 frames</a> section
* <i>4.14. Attached picture</i>
* @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);
}
}
}

View File

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