Skip to content

Commit 1815eec

Browse files
committed
Add support for cover art / thumbnail for ogg downloads
a
1 parent 288a645 commit 1815eec

File tree

6 files changed

+135
-9
lines changed

6 files changed

+135
-9
lines changed

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

Lines changed: 67 additions & 3 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,14 @@ private byte[] makeMetadata() {
299314
.getUploadDate()
300315
.getLocalDateTime()
301316
.format(DateTimeFormatter.ISO_DATE)));
317+
if (thumbnail != null) {
318+
319+
metadata.add(makeOpusPictureTag(thumbnail));
320+
// Alternative approach, but less standard:
321+
// metadata.add(Pair.create("COVERART", Base64.getEncoder()
322+
// .encodeToString(thumb)));
323+
// metadata.add(Pair.create("COVERARTMIME", "image/jpeg"));
324+
}
302325
}
303326

304327
if (DEBUG) {
@@ -309,7 +332,7 @@ private byte[] makeMetadata() {
309332
return makeOpusTagsHeader(metadata);
310333
} else if ("A_VORBIS".equals(webmTrack.codecId)) {
311334
return new byte[]{
312-
0x03, // ¿¿¿???
335+
0x03, // ???
313336
0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, // "vorbis" binary string
314337
0x00, 0x00, 0x00, 0x00, // writing application string size (not present)
315338
0x00, 0x00, 0x00, 0x00 // additional tags count (zero means no tags)
@@ -339,6 +362,47 @@ private static byte[] makeOpusMetadataTag(final Pair<String, String> pair) {
339362
return buf.array();
340363
}
341364

365+
private static Pair<String, String> makeOpusPictureTag(final Bitmap bitmap) {
366+
// FLAC picture block format (big-endian):
367+
// uint32 picture_type
368+
// uint32 mime_length, mime_string
369+
// uint32 desc_length, desc_string
370+
// uint32 width
371+
// uint32 height
372+
// uint32 color_depth
373+
// uint32 colors_indexed
374+
// uint32 data_length, data_bytes
375+
376+
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
377+
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, baos);
378+
bitmap.recycle();
379+
380+
final byte[] imageData = baos.toByteArray();
381+
final byte[] mimeBytes = "image/jpeg".getBytes(StandardCharsets.UTF_8);
382+
final byte[] descBytes = new byte[0]; // optional description
383+
// fixed ints + mime + desc
384+
final int headerSize = 4 * 8 + mimeBytes.length + descBytes.length;
385+
final ByteBuffer buf = ByteBuffer.allocate(headerSize + imageData.length);
386+
buf.putInt(3); // picture type: 3 = Cover (front)
387+
buf.putInt(mimeBytes.length);
388+
buf.put(mimeBytes);
389+
buf.putInt(descBytes.length);
390+
// no description
391+
if (descBytes.length > 0) {
392+
buf.put(descBytes);
393+
}
394+
buf.putInt(bitmap.getWidth()); // width (unknown)
395+
buf.putInt(bitmap.getHeight()); // height (unknown)
396+
buf.putInt(0); // color depth
397+
buf.putInt(0); // colors indexed
398+
buf.putInt(imageData.length);
399+
buf.put(imageData);
400+
final String b64 = Base64.getEncoder().encodeToString(buf.array());
401+
// Many apps also accept COVERART / COVERARTMIME,
402+
// but METADATA_BLOCK_PICTURE is the standard approach
403+
return Pair.create("METADATA_BLOCK_PICTURE", b64);
404+
}
405+
342406
/**
343407
* This returns a complete "OpusTags" header, created from the provided metadata tags.
344408
* <p>

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ static double estimatePixelCount(final Image image, final double widthOverHeight
5757
* @see #choosePreferredImage(List)
5858
*/
5959
@Nullable
60-
static String choosePreferredImage(@NonNull final List<Image> images,
61-
final PreferredImageQuality nonNoneQuality) {
60+
public static String choosePreferredImage(@NonNull final List<Image> images,
61+
final PreferredImageQuality nonNoneQuality) {
6262
// this will be used to estimate the pixel count for images where only one of height or
6363
// width are known
6464
final double widthOverHeight = images.stream()
@@ -189,7 +189,8 @@ public static List<Image> dbUrlToImageList(@Nullable final String url) {
189189
if (url == null) {
190190
return List.of();
191191
} else {
192-
return List.of(new Image(url, -1, -1, Image.ResolutionLevel.UNKNOWN));
192+
return List.of(
193+
new Image(url, HEIGHT_UNKNOWN, WIDTH_UNKNOWN, Image.ResolutionLevel.UNKNOWN));
193194
}
194195
}
195196
}

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

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

3+
import android.graphics.Bitmap;
34
import android.os.Handler;
45
import android.system.ErrnoException;
56
import android.system.OsConstants;
@@ -8,6 +9,7 @@
89
import androidx.annotation.NonNull;
910
import androidx.annotation.Nullable;
1011

12+
import org.schabi.newpipe.App;
1113
import org.schabi.newpipe.DownloaderImpl;
1214

1315
import java.io.File;
@@ -21,11 +23,18 @@
2123
import java.net.URL;
2224
import java.net.UnknownHostException;
2325
import java.nio.channels.ClosedByInterruptException;
26+
import java.util.List;
2427
import java.util.Objects;
2528

2629
import javax.net.ssl.SSLException;
2730

31+
import org.schabi.newpipe.extractor.Image;
32+
import org.schabi.newpipe.extractor.stream.StreamInfo;
2833
import org.schabi.newpipe.streams.io.StoredFileHelper;
34+
import org.schabi.newpipe.util.image.CoilHelper;
35+
import org.schabi.newpipe.util.image.ImageStrategy;
36+
import org.schabi.newpipe.util.image.PreferredImageQuality;
37+
2938
import us.shandian.giga.postprocessing.Postprocessing;
3039
import us.shandian.giga.service.DownloadManagerService;
3140
import us.shandian.giga.util.Utility;
@@ -58,6 +67,10 @@ public class DownloadMission extends Mission {
5867
public static final int ERROR_HTTP_NO_CONTENT = 204;
5968
static final int ERROR_HTTP_FORBIDDEN = 403;
6069

70+
private StreamInfo streamInfo;
71+
protected volatile Bitmap thumbnail;
72+
protected volatile boolean thumbnailFetched = false;
73+
6174
/**
6275
* The urls of the file to download
6376
*/
@@ -153,7 +166,8 @@ public class DownloadMission extends Mission {
153166
public transient Thread[] threads = new Thread[0];
154167
public transient Thread init = null;
155168

156-
public DownloadMission(String[] urls, StoredFileHelper storage, char kind, Postprocessing psInstance) {
169+
public DownloadMission(String[] urls, StoredFileHelper storage, char kind,
170+
Postprocessing psInstance, StreamInfo streamInfo) {
157171
if (Objects.requireNonNull(urls).length < 1)
158172
throw new IllegalArgumentException("urls array is empty");
159173
this.urls = urls;
@@ -163,6 +177,7 @@ public DownloadMission(String[] urls, StoredFileHelper storage, char kind, Postp
163177
this.maxRetry = 3;
164178
this.storage = storage;
165179
this.psAlgorithm = psInstance;
180+
this.streamInfo = streamInfo;
166181

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

700715
try {
716+
psAlgorithm.setThumbnail(thumbnail);
701717
psAlgorithm.run(this);
702718
} catch (Exception err) {
703719
Log.e(TAG, "Post-processing failed. " + psAlgorithm.toString(), err);
@@ -829,6 +845,30 @@ private void joinForThreads(int millis) {
829845
}
830846
}
831847

848+
/**
849+
* Loads the thumbnail / cover art from a list of thumbnails.
850+
* The highest quality is selected.
851+
*
852+
* @param images the list of thumbnails
853+
*/
854+
public void fetchThumbnail(@NonNull final List<Image> images) {
855+
if (images.isEmpty()) {
856+
thumbnailFetched = true;
857+
return;
858+
}
859+
860+
try {
861+
final String thumbnailUrl = ImageStrategy.choosePreferredImage(
862+
images, PreferredImageQuality.HIGH);
863+
// TODO: get context from somewhere else
864+
thumbnail = CoilHelper.INSTANCE.loadBitmapBlocking(App.getInstance(), thumbnailUrl);
865+
thumbnailFetched = true;
866+
} catch (final Exception e) {
867+
Log.w(TAG, "fetchThumbnail: failed to load thumbnail", e);
868+
thumbnailFetched = true;
869+
return;
870+
}
871+
}
832872

833873
static class HttpError extends Exception {
834874
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 & 1 deletion
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,7 @@ 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(urls, storage, kind, ps, streamInfo);
412413
mission.threadCount = threads;
413414
mission.source = streamInfo.getUrl();
414415
mission.nearLength = nearLength;
@@ -417,6 +418,19 @@ private void startMission(Intent intent) {
417418
if (ps != null)
418419
ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this));
419420

421+
if (streamInfo != null) {
422+
new Thread(() -> {
423+
try {
424+
// erwartet, dass StreamInfo eine Methode liefert, die List<Image> zurückgibt,
425+
// z.B. getThumbnailImages(). Falls nicht vorhanden, anpassen auf die vorhandene API.
426+
List<Image> images = streamInfo.getThumbnails();
427+
mission.fetchThumbnail(images);
428+
} catch (Exception e) {
429+
Log.w(TAG, "failed to fetch thumbnail for mission: " + mission.storage.getName(), e);
430+
}
431+
}, "ThumbnailFetcher").start();
432+
}
433+
420434
handleConnectivityState(true);// first check the actual network status
421435

422436
mManager.startMission(mission);

0 commit comments

Comments
 (0)