Skip to content

Commit ba1ceec

Browse files
committed
Add support for cover art / thumbnail for ogg downloads
1 parent 830d781 commit ba1ceec

File tree

6 files changed

+143
-9
lines changed

6 files changed

+143
-9
lines changed

app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import static org.schabi.newpipe.MainActivity.DEBUG;
44

5+
import android.graphics.Bitmap;
56
import android.util.Log;
67
import android.util.Pair;
78

@@ -15,12 +16,15 @@
1516
import org.schabi.newpipe.streams.WebMReader.WebMTrack;
1617
import org.schabi.newpipe.streams.io.SharpStream;
1718

19+
import java.io.ByteArrayOutputStream;
1820
import java.io.Closeable;
1921
import java.io.IOException;
2022
import java.nio.ByteBuffer;
2123
import java.nio.ByteOrder;
24+
import java.nio.charset.StandardCharsets;
2225
import java.time.format.DateTimeFormatter;
2326
import java.util.ArrayList;
27+
import java.util.Base64;
2428
import java.util.List;
2529
import java.util.stream.Collectors;
2630

@@ -63,9 +67,19 @@ public class OggFromWebMWriter implements Closeable {
6367

6468
private final int[] crc32Table = new int[256];
6569
private final StreamInfo streamInfo;
70+
private final Bitmap thumbnail;
6671

67-
public OggFromWebMWriter(@NonNull final SharpStream source, @NonNull final SharpStream target,
68-
@Nullable final StreamInfo streamInfo) {
72+
/**
73+
* Constructor of OggFromWebMWriter.
74+
* @param source
75+
* @param target
76+
* @param streamInfo the stream info
77+
* @param thumbnail the thumbnail bitmap used as cover art
78+
*/
79+
public OggFromWebMWriter(@NonNull final SharpStream source,
80+
@NonNull final SharpStream target,
81+
@Nullable final StreamInfo streamInfo,
82+
@Nullable final Bitmap thumbnail) {
6983
if (!source.canRead() || !source.canRewind()) {
7084
throw new IllegalArgumentException("source stream must be readable and allows seeking");
7185
}
@@ -76,6 +90,7 @@ public OggFromWebMWriter(@NonNull final SharpStream source, @NonNull final Sharp
7690
this.source = source;
7791
this.output = target;
7892
this.streamInfo = streamInfo;
93+
this.thumbnail = thumbnail;
7994

8095
this.streamId = (int) System.currentTimeMillis();
8196

@@ -299,6 +314,9 @@ private byte[] makeMetadata() {
299314
.getUploadDate()
300315
.getLocalDateTime()
301316
.format(DateTimeFormatter.ISO_DATE)));
317+
if (thumbnail != null) {
318+
metadata.add(makeOpusPictureTag(thumbnail));
319+
}
302320
}
303321

304322
if (DEBUG) {
@@ -309,7 +327,7 @@ private byte[] makeMetadata() {
309327
return makeOpusTagsHeader(metadata);
310328
} else if ("A_VORBIS".equals(webmTrack.codecId)) {
311329
return new byte[]{
312-
0x03, // ¿¿¿???
330+
0x03, // ???
313331
0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, // "vorbis" binary string
314332
0x00, 0x00, 0x00, 0x00, // writing application string size (not present)
315333
0x00, 0x00, 0x00, 0x00 // additional tags count (zero means no tags)
@@ -339,6 +357,56 @@ private static byte[] makeOpusMetadataTag(final Pair<String, String> pair) {
339357
return buf.array();
340358
}
341359

360+
/**
361+
* Adds the {@code METADATA_BLOCK_PICTURE} tag to the Opus metadata,
362+
* containing the provided bitmap as cover art.
363+
*
364+
* <p>
365+
* One could also use the COVERART tag instead, but it is not as widely supported
366+
* as METADATA_BLOCK_PICTURE.
367+
* </p>
368+
*
369+
* @param bitmap The bitmap to use as cover art
370+
* @return The key-value pair representing the tag
371+
*/
372+
private static Pair<String, String> makeOpusPictureTag(final Bitmap bitmap) {
373+
// FLAC picture block format (big-endian):
374+
// uint32 picture_type
375+
// uint32 mime_length, mime_string
376+
// uint32 desc_length, desc_string
377+
// uint32 width
378+
// uint32 height
379+
// uint32 color_depth
380+
// uint32 colors_indexed
381+
// uint32 data_length, data_bytes
382+
383+
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
384+
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, baos);
385+
386+
final byte[] imageData = baos.toByteArray();
387+
final byte[] mimeBytes = "image/jpeg".getBytes(StandardCharsets.UTF_8);
388+
final byte[] descBytes = new byte[0]; // optional description
389+
// fixed ints + mime + desc
390+
final int headerSize = 4 * 8 + mimeBytes.length + descBytes.length;
391+
final ByteBuffer buf = ByteBuffer.allocate(headerSize + imageData.length);
392+
buf.putInt(3); // picture type: 3 = Cover (front)
393+
buf.putInt(mimeBytes.length);
394+
buf.put(mimeBytes);
395+
buf.putInt(descBytes.length);
396+
// no description
397+
if (descBytes.length > 0) {
398+
buf.put(descBytes);
399+
}
400+
buf.putInt(bitmap.getWidth()); // width (unknown)
401+
buf.putInt(bitmap.getHeight()); // height (unknown)
402+
buf.putInt(0); // color depth
403+
buf.putInt(0); // colors indexed
404+
buf.putInt(imageData.length);
405+
buf.put(imageData);
406+
final String b64 = Base64.getEncoder().encodeToString(buf.array());
407+
return Pair.create("METADATA_BLOCK_PICTURE", b64);
408+
}
409+
342410
/**
343411
* This returns a complete "OpusTags" header, created from the provided metadata tags.
344412
* <p>
@@ -447,7 +515,8 @@ private boolean addPacketSegment(final SimpleBlock block) {
447515

448516
private boolean addPacketSegment(final int size) {
449517
if (size > 65025) {
450-
throw new UnsupportedOperationException("page size cannot be larger than 65025");
518+
throw new UnsupportedOperationException(
519+
String.format("page size is %s but cannot be larger than 65025", size));
451520
}
452521

453522
int available = (segmentTable.length - segmentTableSize) * 255;

app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ object ImageStrategy {
185185
fun dbUrlToImageList(url: String?): List<Image> {
186186
return when (url) {
187187
null -> listOf()
188-
else -> listOf(Image(url, -1, -1, ResolutionLevel.UNKNOWN))
188+
else -> listOf(Image(url, Image.HEIGHT_UNKNOWN, Image.WIDTH_UNKNOWN, ResolutionLevel.UNKNOWN))
189189
}
190190
}
191191
}

app/src/main/java/us/shandian/giga/get/DownloadMission.java

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package us.shandian.giga.get;
22

3+
import android.content.Context;
4+
import android.graphics.Bitmap;
35
import android.os.Handler;
46
import android.system.ErrnoException;
57
import android.system.OsConstants;
@@ -8,6 +10,7 @@
810
import androidx.annotation.NonNull;
911
import androidx.annotation.Nullable;
1012

13+
import org.schabi.newpipe.App;
1114
import org.schabi.newpipe.DownloaderImpl;
1215

1316
import java.io.File;
@@ -21,11 +24,18 @@
2124
import java.net.URL;
2225
import java.net.UnknownHostException;
2326
import java.nio.channels.ClosedByInterruptException;
27+
import java.util.List;
2428
import java.util.Objects;
2529

2630
import javax.net.ssl.SSLException;
2731

32+
import org.schabi.newpipe.extractor.Image;
33+
import org.schabi.newpipe.extractor.stream.StreamInfo;
2834
import org.schabi.newpipe.streams.io.StoredFileHelper;
35+
import org.schabi.newpipe.util.image.CoilHelper;
36+
import org.schabi.newpipe.util.image.ImageStrategy;
37+
import org.schabi.newpipe.util.image.PreferredImageQuality;
38+
2939
import us.shandian.giga.postprocessing.Postprocessing;
3040
import us.shandian.giga.service.DownloadManagerService;
3141
import us.shandian.giga.util.Utility;
@@ -58,6 +68,10 @@ public class DownloadMission extends Mission {
5868
public static final int ERROR_HTTP_NO_CONTENT = 204;
5969
static final int ERROR_HTTP_FORBIDDEN = 403;
6070

71+
private StreamInfo streamInfo;
72+
protected volatile Bitmap thumbnail;
73+
protected volatile boolean thumbnailFetched = false;
74+
6175
/**
6276
* The urls of the file to download
6377
*/
@@ -153,7 +167,8 @@ public class DownloadMission extends Mission {
153167
public transient Thread[] threads = new Thread[0];
154168
public transient Thread init = null;
155169

156-
public DownloadMission(String[] urls, StoredFileHelper storage, char kind, Postprocessing psInstance) {
170+
public DownloadMission(String[] urls, StoredFileHelper storage, char kind,
171+
Postprocessing psInstance, StreamInfo streamInfo, Context context) {
157172
if (Objects.requireNonNull(urls).length < 1)
158173
throw new IllegalArgumentException("urls array is empty");
159174
this.urls = urls;
@@ -163,6 +178,7 @@ public DownloadMission(String[] urls, StoredFileHelper storage, char kind, Postp
163178
this.maxRetry = 3;
164179
this.storage = storage;
165180
this.psAlgorithm = psInstance;
181+
this.streamInfo = streamInfo;
166182

167183
if (DEBUG && psInstance == null && urls.length > 1) {
168184
Log.w(TAG, "mission created with multiple urls ¿missing post-processing algorithm?");
@@ -698,6 +714,7 @@ private void doPostprocessing() {
698714
Exception exception = null;
699715

700716
try {
717+
psAlgorithm.setThumbnail(thumbnail);
701718
psAlgorithm.run(this);
702719
} catch (Exception err) {
703720
Log.e(TAG, "Post-processing failed. " + psAlgorithm.toString(), err);
@@ -829,6 +846,34 @@ private void joinForThreads(int millis) {
829846
}
830847
}
831848

849+
/**
850+
* Loads the thumbnail / cover art from a list of thumbnails.
851+
* The highest quality is selected.
852+
*
853+
* @param images the list of thumbnails
854+
*/
855+
public void fetchThumbnail(@NonNull final List<Image> images) {
856+
if (images.isEmpty()) {
857+
thumbnailFetched = true;
858+
return;
859+
}
860+
861+
try {
862+
// Some containers have a limited size for embedded images / metadata.
863+
// To avoid problems, we download a medium quality image.
864+
// Alternative approaches are to either downscale a high res image or
865+
// to download the correct size depending on the chosen post-processing algorithm.
866+
final String thumbnailUrl = ImageStrategy.choosePreferredImage(
867+
images, PreferredImageQuality.MEDIUM);
868+
// TODO: get context from somewhere else
869+
thumbnail = CoilHelper.INSTANCE.loadBitmapBlocking(App.getInstance(), thumbnailUrl);
870+
thumbnailFetched = true;
871+
} catch (final Exception e) {
872+
Log.w(TAG, "fetchThumbnail: failed to load thumbnail", e);
873+
thumbnailFetched = true;
874+
return;
875+
}
876+
}
832877

833878
static class HttpError extends Exception {
834879
final int statusCode;

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ boolean test(SharpStream... sources) throws IOException {
3434

3535
@Override
3636
int process(SharpStream out, @NonNull SharpStream... sources) throws IOException {
37-
OggFromWebMWriter demuxer = new OggFromWebMWriter(sources[0], out, streamInfo);
37+
OggFromWebMWriter demuxer = new OggFromWebMWriter(
38+
sources[0], out, streamInfo, thumbnail);
3839
demuxer.parseSource();
3940
demuxer.selectTrack(0);
4041
demuxer.build();

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package us.shandian.giga.postprocessing;
22

3+
import android.graphics.Bitmap;
34
import android.util.Log;
45

56
import androidx.annotation.NonNull;
@@ -80,6 +81,7 @@ public static Postprocessing getAlgorithm(@NonNull String algorithmName, String[
8081

8182
private String[] args;
8283
protected StreamInfo streamInfo;
84+
protected Bitmap thumbnail;
8385

8486
private transient DownloadMission mission;
8587

@@ -107,6 +109,10 @@ public void cleanupTemporalDir() {
107109
}
108110
}
109111

112+
public void setThumbnail(Bitmap thumbnail) {
113+
this.thumbnail = thumbnail;
114+
}
115+
110116

111117
public void run(DownloadMission target) throws IOException {
112118
this.mission = target;

app/src/main/java/us/shandian/giga/service/DownloadManagerService.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040

4141
import org.schabi.newpipe.R;
4242
import org.schabi.newpipe.download.DownloadActivity;
43+
import org.schabi.newpipe.extractor.Image;
4344
import org.schabi.newpipe.extractor.stream.StreamInfo;
4445
import org.schabi.newpipe.player.helper.LockManager;
4546
import org.schabi.newpipe.streams.io.StoredDirectoryHelper;
@@ -408,7 +409,8 @@ private void startMission(Intent intent) {
408409
else
409410
ps = Postprocessing.getAlgorithm(psName, psArgs, streamInfo);
410411

411-
final DownloadMission mission = new DownloadMission(urls, storage, kind, ps);
412+
final DownloadMission mission = new DownloadMission(
413+
urls, storage, kind, ps, streamInfo, getApplicationContext());
412414
mission.threadCount = threads;
413415
mission.source = streamInfo.getUrl();
414416
mission.nearLength = nearLength;
@@ -417,7 +419,18 @@ private void startMission(Intent intent) {
417419
if (ps != null)
418420
ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this));
419421

420-
handleConnectivityState(true);// first check the actual network status
422+
if (streamInfo != null) {
423+
new Thread(() -> {
424+
try {
425+
mission.fetchThumbnail(streamInfo.getThumbnails());
426+
} catch (Exception e) {
427+
Log.w(TAG, "failed to fetch thumbnail for mission: "
428+
+ mission.storage.getName(), e);
429+
}
430+
}, "ThumbnailFetcher").start();
431+
}
432+
433+
handleConnectivityState(true); // first check the actual network status
421434

422435
mManager.startMission(mission);
423436
}

0 commit comments

Comments
 (0)