Skip to content

Commit 282fced

Browse files
committed
Add metadata and cover art to mp3 files
1 parent 9bbf78b commit 282fced

File tree

3 files changed

+332
-5
lines changed

3 files changed

+332
-5
lines changed

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,11 +1062,13 @@ private void continueSelectedDownload(@NonNull final StoredFileHelper storage) {
10621062
kind = 'a';
10631063
selectedStream = audioStreamsAdapter.getItem(selectedAudioIndex);
10641064

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

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)