Skip to content

Commit b904679

Browse files
committed
Add metadata and cover art to mp3 files
1 parent 5d550a6 commit b904679

File tree

3 files changed

+352
-5
lines changed

3 files changed

+352
-5
lines changed

app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,11 +1049,12 @@ private void continueSelectedDownload(@NonNull final StoredFileHelper storage) {
10491049
kind = 'a';
10501050
selectedStream = audioStreamsAdapter.getItem(selectedAudioIndex);
10511051

1052-
if (selectedStream.getFormat() == MediaFormat.M4A) {
1053-
psName = Postprocessing.ALGORITHM_M4A_NO_DASH;
1054-
} else if (selectedStream.getFormat() == MediaFormat.WEBMA_OPUS) {
1055-
psName = Postprocessing.ALGORITHM_OGG_FROM_WEBM_DEMUXER;
1056-
}
1052+
psName = switch (selectedStream.getFormat()) {
1053+
case M4A -> Postprocessing.ALGORITHM_M4A_NO_DASH;
1054+
case WEBMA_OPUS -> Postprocessing.ALGORITHM_OGG_FROM_WEBM_DEMUXER;
1055+
case MP3 -> Postprocessing.ALGORITHM_MP3_METADATA;
1056+
default -> null;
1057+
};
10571058
} else if (checkedRadioButtonId == R.id.video_button) {
10581059
kind = 'v';
10591060
selectedStream = videoStreamsAdapter.getItem(selectedVideoIndex);
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 NewPipe contributors <https://newpipe.net>
3+
* SPDX-License-Identifier: GPL-3.0-or-later
4+
*/
5+
6+
package us.shandian.giga.postprocessing;
7+
8+
import static java.time.ZoneOffset.UTC;
9+
10+
import android.graphics.Bitmap;
11+
12+
import org.schabi.newpipe.streams.io.SharpInputStream;
13+
import org.schabi.newpipe.streams.io.SharpStream;
14+
import org.schabi.newpipe.util.StreamInfoMetadataHelper;
15+
16+
import java.io.ByteArrayOutputStream;
17+
import java.io.IOException;
18+
import java.io.InputStream;
19+
import java.io.PushbackInputStream;
20+
import java.nio.charset.StandardCharsets;
21+
import java.time.LocalDateTime;
22+
import java.time.format.DateTimeFormatter;
23+
import java.util.List;
24+
25+
import javax.annotation.Nonnull;
26+
27+
/**
28+
* Adds Metadata to an MP3 file by writing ID3v2.4 frames, i.e. metadata tags,
29+
* at the start of the file.
30+
* @see <a href="https://id3.org/id3v2.4.0-structure">ID3v2.4 specification</a>
31+
* @see <a href="https://id3.org/id3v2.4.0-frames">ID3v2.4 frames</a>
32+
*/
33+
public class Mp3Metadata extends Postprocessing {
34+
/**
35+
* ID3v2 tags are stored at the start of the MP3 file and consist of a 10-byte header
36+
* followed by a sequence of frames.
37+
* <br>
38+
* The header contains the
39+
* <ul>
40+
* <li>tag identifier (3 bytes),</li>
41+
* <li>version (1 byte),</li>
42+
* <li>revision (1 byte),</li>
43+
* <li>flags (1 byte),</li>
44+
* <li>and the size of the tag (excluding the header) as a synchsafe integer (4 bytes).</li>
45+
* </ul>
46+
*/
47+
private static final int ID3_HEADER_SIZE = 10;
48+
49+
Mp3Metadata() {
50+
super(true, true, ALGORITHM_MP3_METADATA);
51+
}
52+
53+
@Override
54+
int process(SharpStream out, SharpStream... sources) throws IOException {
55+
if (sources == null || sources.length == 0 || sources[0] == null) {
56+
// nothing to do
57+
return OK_RESULT;
58+
}
59+
60+
// MP3 metadata is stored in ID3v2 tags at the start of the file,
61+
// so we need to build the tag in memory first and then write it
62+
// before copying the rest of the file.
63+
64+
final ByteArrayOutputStream frames = new ByteArrayOutputStream();
65+
final FrameWriter fw = new FrameWriter(frames);
66+
67+
makeMetadata(fw);
68+
makePictureFrame(fw);
69+
70+
byte[] framesBytes = frames.toByteArray();
71+
final int tagSize = framesBytes.length; // size excluding 10-byte header
72+
73+
out.write(new byte[]{
74+
'I', 'D', '3',
75+
0x04, // version 2.4
76+
0x00, // revision
77+
0x00, // flags
78+
});
79+
out.write(toSynchsafe(tagSize));
80+
out.write(framesBytes);
81+
82+
// copy the rest of the file, skipping any existing ID3v2 tag if present
83+
try (InputStream sIn = new SharpInputStream(sources[0])) {
84+
copyStreamSkippingId3(sIn, out);
85+
}
86+
out.flush();
87+
88+
return OK_RESULT;
89+
}
90+
91+
/**
92+
* Write metadata frames based on the StreamInfo's metadata.
93+
* @see <a href="https://id3.org/id3v2.4.0-frames">ID3v2.4 frames</a> for a list of frame types
94+
* and their identifiers.
95+
* @param fw the FrameWriter to write frames to
96+
* @throws IOException if an I/O error occurs while writing frames
97+
*/
98+
private void makeMetadata(@Nonnull final FrameWriter fw) throws IOException {
99+
var metadata = new StreamInfoMetadataHelper(this.streamInfo);
100+
101+
fw.writeTextFrame("TIT2", metadata.getTitle());
102+
fw.writeTextFrame("TPE1", metadata.getArtist());
103+
fw.writeTextFrame("TCOM", metadata.getComposer());
104+
fw.writeTextFrame("TIPL", metadata.getPerformer());
105+
fw.writeTextFrame("TCON", metadata.getGenre());
106+
fw.writeTextFrame("TALB", metadata.getAlbum());
107+
108+
final LocalDateTime releaseDate = metadata.getReleaseDate().getLocalDateTime(UTC);
109+
// determine precision by checking that lower-order fields are at their "zero"/start values
110+
final boolean isOnlyMonth = releaseDate.getDayOfMonth() == 1
111+
&& releaseDate.getHour() == 0
112+
&& releaseDate.getMinute() == 0
113+
&& releaseDate.getSecond() == 0
114+
&& releaseDate.getNano() == 0;
115+
final boolean isOnlyYear = releaseDate.getMonthValue() == 1
116+
&& isOnlyMonth;
117+
// see https://id3.org/id3v2.4.0-structure > 4. ID3v2 frame overview
118+
// for date formats in TDRC frame
119+
final String datePattern;
120+
if (isOnlyYear) {
121+
datePattern = "yyyy";
122+
} else if (isOnlyMonth) {
123+
datePattern = "yyyy-MM";
124+
} else {
125+
datePattern = "yyyy-MM-dd";
126+
}
127+
fw.writeTextFrame("TDRC",
128+
releaseDate.format(DateTimeFormatter.ofPattern(datePattern)));
129+
130+
131+
if (metadata.getTrackNumber() != null) {
132+
fw.writeTextFrame("TRCK", String.valueOf(metadata.getTrackNumber()));
133+
}
134+
135+
fw.writeTextFrame("TPUB", metadata.getRecordLabel());
136+
fw.writeTextFrame("TCOP", metadata.getCopyright());
137+
138+
// WXXX is a user defined URL link frame, we can use it to store the URL of the stream
139+
// However, since it's user defined, so not all players support it.
140+
// Using the comment frame (COMM) as fallback
141+
fw.writeTextFrame("WXXX", streamInfo.getUrl());
142+
fw.writeCommentFrame("eng", streamInfo.getUrl());
143+
}
144+
145+
/**
146+
* Write a picture frame (APIC) with the thumbnail image if available.
147+
* @param fw the FrameWriter to write the picture frame to
148+
* @throws IOException if an I/O error occurs while writing the frame
149+
*/
150+
private void makePictureFrame(FrameWriter fw) throws IOException {
151+
if (thumbnail != null) {
152+
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
153+
thumbnail.compress(Bitmap.CompressFormat.PNG, 100, baos);
154+
final byte[] imgBytes = baos.toByteArray();
155+
baos.close();
156+
fw.writePictureFrame("image/png", imgBytes);
157+
}
158+
}
159+
160+
/**
161+
* Copy the input stream to the output stream, but if the input stream starts with an ID3v2 tag,
162+
* skip the tag and only copy the audio data.
163+
* @param in the input stream to read from (should be at the start of the MP3 file)
164+
* @param out the output stream to write to
165+
* @throws IOException if an I/O error occurs while reading or writing
166+
*/
167+
private static void copyStreamSkippingId3(@Nonnull final InputStream in,
168+
@Nonnull final SharpStream out) throws IOException {
169+
PushbackInputStream pin = (in instanceof PushbackInputStream pis)
170+
? pis : new PushbackInputStream(in, ID3_HEADER_SIZE);
171+
// This assumes that the ID3 tag is at the very start of the file, which is the case
172+
// for ID3v2 tags.
173+
// IDv1 tags are at the end of the file, but ID3v1 is very old and not commonly used
174+
// (IDv2 has been around since 1998 and is more widely supported),
175+
// so we can ignore it for simplicity.
176+
byte[] header = new byte[ID3_HEADER_SIZE];
177+
int hr = pin.read(header);
178+
if (hr == ID3_HEADER_SIZE && header[0] == 'I' && header[1] == 'D' && header[2] == '3') {
179+
// bytes 3 and 4 are version and revision and byte 5 is flags
180+
// the size is stored as synchsafe at bytes 6..9
181+
int size = fromSynchsafe(header, 6);
182+
long remaining = size;
183+
// consume exactly 'size' bytes, i.e. the rest of the metadata frames, from the stream
184+
byte[] skipBuf = new byte[8192];
185+
while (remaining > 0) {
186+
int toRead = (int) Math.min(skipBuf.length, remaining);
187+
int r = pin.read(skipBuf, 0, toRead);
188+
if (r <= 0) break;
189+
remaining -= r;
190+
}
191+
} else {
192+
// push header bytes back so copy will include them
193+
if (hr > 0) pin.unread(header, 0, hr);
194+
}
195+
196+
// copy rest
197+
byte[] buf = new byte[8192];
198+
int r;
199+
while ((r = pin.read(buf)) > 0) out.write(buf, 0, r);
200+
}
201+
202+
/**
203+
* Create a 4-byte synchsafe integer from a regular integer value.
204+
* @see <a href="https://id3.org/id3v2.4.0-structure">ID3v2.4 specification</a> section
205+
* <i>6.2. Synchsafe integers</i>
206+
* @param value the integer value to convert (should be non-negative and less than 2^28)
207+
* @return the synchsafe byte array
208+
*/
209+
private static byte[] toSynchsafe(int value) {
210+
byte[] b = new byte[4];
211+
b[0] = (byte) ((value >> 21) & 0x7F);
212+
b[1] = (byte) ((value >> 14) & 0x7F);
213+
b[2] = (byte) ((value >> 7) & 0x7F);
214+
b[3] = (byte) (value & 0x7F);
215+
return b;
216+
}
217+
218+
/**
219+
* Get a regular integer from a 4-byte synchsafe byte array.
220+
* @see <a href="https://id3.org/id3v2.4.0-structure">ID3v2.4 specification</a> section
221+
* <i>6.2. Synchsafe integers</i>
222+
* @param b the byte array containing the synchsafe integer
223+
* (should be at least 4 bytes + offset long)
224+
* @param offset the offset in the byte array where the synchsafe integer starts
225+
* @return the regular integer value
226+
*/
227+
private static int fromSynchsafe(byte[] b, int offset) {
228+
return ((b[offset] & 0x7F) << 21)
229+
| ((b[offset + 1] & 0x7F) << 14)
230+
| ((b[offset + 2] & 0x7F) << 7)
231+
| (b[offset + 3] & 0x7F);
232+
}
233+
234+
/**
235+
* Helper class to write ID3v2.4 frames to a ByteArrayOutputStream.
236+
*/
237+
private static class FrameWriter {
238+
239+
/**
240+
* This separator is used to separate multiple entries in a list of an ID3v2 text frame.
241+
* @see <a href="https://id3.org/id3v2.4.0-frames">ID3v2.4 frames</a> section
242+
* <i>4.2. Text information frames</i>
243+
*/
244+
private static final Character TEXT_LIST_SEPARATOR = 0x00;
245+
private static final byte UTF8_ENCODING_BYTE = 0x03;
246+
247+
private final ByteArrayOutputStream out;
248+
249+
FrameWriter(ByteArrayOutputStream out) {
250+
this.out = out;
251+
}
252+
253+
/**
254+
* Write a text frame with the given identifier and text content.
255+
* @param id the 4 character long frame identifier
256+
* @param text the text content to write. If null or blank, no frame is written.
257+
* @throws IOException if an I/O error occurs while writing the frame
258+
*/
259+
void writeTextFrame(String id, String text) throws IOException {
260+
if (text == null || text.isBlank()) return;
261+
byte[] data = text.getBytes(StandardCharsets.UTF_8);
262+
ByteArrayOutputStream frame = new ByteArrayOutputStream();
263+
frame.write(UTF8_ENCODING_BYTE);
264+
frame.write(data);
265+
writeFrame(id, frame.toByteArray());
266+
}
267+
268+
/**
269+
* Write a text frame that can contain multiple entries separated by the
270+
* {@link #TEXT_LIST_SEPARATOR}.
271+
* @param id the 4 character long frame identifier
272+
* @param texts the list of text entries to write. If null or empty, no frame is written.
273+
* Blank or null entries are skipped.
274+
* @throws IOException if an I/O error occurs while writing the frame
275+
*/
276+
void writeTextFrame(String id, List<String> texts) throws IOException {
277+
if (texts == null || texts.isEmpty()) return;
278+
ByteArrayOutputStream frame = new ByteArrayOutputStream();
279+
frame.write(UTF8_ENCODING_BYTE);
280+
for (int i = 0; i < texts.size(); i++) {
281+
String text = texts.get(i);
282+
if (text != null && !text.isBlank()) {
283+
byte[] data = text.getBytes(StandardCharsets.UTF_8);
284+
frame.write(data);
285+
if (i < texts.size() - 1) {
286+
frame.write(TEXT_LIST_SEPARATOR);
287+
}
288+
}
289+
}
290+
writeFrame(id, frame.toByteArray());
291+
}
292+
293+
/**
294+
* Write a picture frame (APIC) with the given MIME type and image data.
295+
* @see <a href="https://id3.org/id3v2.4.0-frames">ID3v2.4 frames</a> section
296+
* <i>4.14. Attached picture</i>
297+
* @param mimeType the MIME type of the image (e.g. "image/png" or "image/jpeg").
298+
* @param imageData the binary data of the image. If empty, no frame is written.
299+
* @throws IOException
300+
*/
301+
void writePictureFrame(@Nonnull String mimeType, @Nonnull byte[] imageData)
302+
throws IOException {
303+
if (imageData.length == 0) return;
304+
ByteArrayOutputStream frame = new ByteArrayOutputStream();
305+
frame.write(UTF8_ENCODING_BYTE);
306+
frame.write(mimeType.getBytes(StandardCharsets.US_ASCII));
307+
frame.write(0x00);
308+
frame.write(0x03); // picture type: 3 = cover(front)
309+
frame.write(0x00); // empty description terminator (UTF-8 empty string)
310+
// Then the picture bytes
311+
frame.write(imageData);
312+
writeFrame("APIC", frame.toByteArray());
313+
}
314+
315+
/**
316+
* Write a comment frame (COMM) with the given language and comment text.
317+
* @param lang a 3-character ISO-639-2 language code (e.g. "eng" for English).
318+
* If null or invalid, defaults to "eng".
319+
* @param comment the comment text to write. If null, no frame is written.
320+
* @throws IOException
321+
*/
322+
void writeCommentFrame(String lang, String comment) throws IOException {
323+
if (comment == null) return;
324+
if (lang == null || lang.length() != 3) lang = "eng";
325+
ByteArrayOutputStream frame = new ByteArrayOutputStream();
326+
frame.write(UTF8_ENCODING_BYTE);
327+
frame.write(lang.getBytes(StandardCharsets.US_ASCII));
328+
frame.write(0x00); // short content descriptor (empty) terminator
329+
frame.write(comment.getBytes(StandardCharsets.UTF_8));
330+
writeFrame("COMM", frame.toByteArray());
331+
}
332+
333+
private void writeFrame(String id, byte[] data) throws IOException {
334+
if (data == null || data.length == 0) return;
335+
// frame header: id(4) size(4 synchsafe) flags(2)
336+
out.write(id.getBytes(StandardCharsets.US_ASCII));
337+
out.write(toSynchsafe(data.length));
338+
out.write(new byte[]{0x00, 0x00});
339+
out.write(data);
340+
}
341+
}
342+
}

app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public abstract class Postprocessing implements Serializable {
2929

3030
public static final String ALGORITHM_TTML_CONVERTER = "ttml";
3131
public static final String ALGORITHM_WEBM_MUXER = "webm";
32+
public static final String ALGORITHM_MP3_METADATA = "mp3-metadata";
3233
public static final String ALGORITHM_MP4_METADATA = "mp4-metadata";
3334
public static final String ALGORITHM_MP4_FROM_DASH_MUXER = "mp4D-mp4";
3435
public static final String ALGORITHM_M4A_NO_DASH = "mp4D-m4a";
@@ -45,6 +46,9 @@ public static Postprocessing getAlgorithm(@NonNull String algorithmName, String[
4546
case ALGORITHM_WEBM_MUXER:
4647
instance = new WebMMuxer();
4748
break;
49+
case ALGORITHM_MP3_METADATA:
50+
instance = new Mp3Metadata();
51+
break;
4852
case ALGORITHM_MP4_METADATA:
4953
instance = new Mp4Metadata();
5054
break;

0 commit comments

Comments
 (0)