From e0a1011cd63a0c86753b4b4214f0631e4fd33e9d Mon Sep 17 00:00:00 2001 From: tobigr Date: Sat, 3 Jan 2026 20:17:41 +0100 Subject: [PATCH] Add support for cover art / thumbnail for ogg downloads --- .../newpipe/streams/OggFromWebMWriter.java | 77 ++++++++++++++++++- .../newpipe/util/image/ImageStrategy.kt | 2 +- .../us/shandian/giga/get/DownloadMission.java | 47 ++++++++++- .../postprocessing/OggFromWebmDemuxer.java | 3 +- .../giga/postprocessing/Postprocessing.java | 6 ++ .../giga/service/DownloadManagerService.java | 17 +++- 6 files changed, 143 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java index db11d7c9a..1b597a711 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java @@ -2,6 +2,7 @@ package org.schabi.newpipe.streams; import static org.schabi.newpipe.MainActivity.DEBUG; +import android.graphics.Bitmap; import android.util.Log; import android.util.Pair; @@ -15,12 +16,15 @@ import org.schabi.newpipe.streams.WebMReader.SimpleBlock; import org.schabi.newpipe.streams.WebMReader.WebMTrack; import org.schabi.newpipe.streams.io.SharpStream; +import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Base64; import java.util.List; import java.util.stream.Collectors; @@ -63,9 +67,19 @@ public class OggFromWebMWriter implements Closeable { private final int[] crc32Table = new int[256]; private final StreamInfo streamInfo; + private final Bitmap thumbnail; - public OggFromWebMWriter(@NonNull final SharpStream source, @NonNull final SharpStream target, - @Nullable final StreamInfo streamInfo) { + /** + * Constructor of OggFromWebMWriter. + * @param source + * @param target + * @param streamInfo the stream info + * @param thumbnail the thumbnail bitmap used as cover art + */ + public OggFromWebMWriter(@NonNull final SharpStream source, + @NonNull final SharpStream target, + @Nullable final StreamInfo streamInfo, + @Nullable final Bitmap thumbnail) { if (!source.canRead() || !source.canRewind()) { throw new IllegalArgumentException("source stream must be readable and allows seeking"); } @@ -76,6 +90,7 @@ public class OggFromWebMWriter implements Closeable { this.source = source; this.output = target; this.streamInfo = streamInfo; + this.thumbnail = thumbnail; this.streamId = (int) System.currentTimeMillis(); @@ -299,6 +314,9 @@ public class OggFromWebMWriter implements Closeable { .getUploadDate() .getLocalDateTime() .format(DateTimeFormatter.ISO_DATE))); + if (thumbnail != null) { + metadata.add(makeOpusPictureTag(thumbnail)); + } } if (DEBUG) { @@ -309,7 +327,7 @@ public class OggFromWebMWriter implements Closeable { return makeOpusTagsHeader(metadata); } else if ("A_VORBIS".equals(webmTrack.codecId)) { return new byte[]{ - 0x03, // ¿¿¿??? + 0x03, // ??? 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, // "vorbis" binary string 0x00, 0x00, 0x00, 0x00, // writing application string size (not present) 0x00, 0x00, 0x00, 0x00 // additional tags count (zero means no tags) @@ -339,6 +357,56 @@ public class OggFromWebMWriter implements Closeable { return buf.array(); } + /** + * Adds the {@code METADATA_BLOCK_PICTURE} tag to the Opus metadata, + * containing the provided bitmap as cover art. + * + *

+ * One could also use the COVERART tag instead, but it is not as widely supported + * as METADATA_BLOCK_PICTURE. + *

+ * + * @param bitmap The bitmap to use as cover art + * @return The key-value pair representing the tag + */ + private static Pair makeOpusPictureTag(final Bitmap bitmap) { + // FLAC picture block format (big-endian): + // uint32 picture_type + // uint32 mime_length, mime_string + // uint32 desc_length, desc_string + // uint32 width + // uint32 height + // uint32 color_depth + // uint32 colors_indexed + // uint32 data_length, data_bytes + + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, baos); + + final byte[] imageData = baos.toByteArray(); + final byte[] mimeBytes = "image/jpeg".getBytes(StandardCharsets.UTF_8); + final byte[] descBytes = new byte[0]; // optional description + // fixed ints + mime + desc + final int headerSize = 4 * 8 + mimeBytes.length + descBytes.length; + final ByteBuffer buf = ByteBuffer.allocate(headerSize + imageData.length); + buf.putInt(3); // picture type: 3 = Cover (front) + buf.putInt(mimeBytes.length); + buf.put(mimeBytes); + buf.putInt(descBytes.length); + // no description + if (descBytes.length > 0) { + buf.put(descBytes); + } + buf.putInt(bitmap.getWidth()); // width (unknown) + buf.putInt(bitmap.getHeight()); // height (unknown) + buf.putInt(0); // color depth + buf.putInt(0); // colors indexed + buf.putInt(imageData.length); + buf.put(imageData); + final String b64 = Base64.getEncoder().encodeToString(buf.array()); + return Pair.create("METADATA_BLOCK_PICTURE", b64); + } + /** * This returns a complete "OpusTags" header, created from the provided metadata tags. *

@@ -447,7 +515,8 @@ public class OggFromWebMWriter implements Closeable { private boolean addPacketSegment(final int size) { if (size > 65025) { - throw new UnsupportedOperationException("page size cannot be larger than 65025"); + throw new UnsupportedOperationException( + String.format("page size is %s but cannot be larger than 65025", size)); } int available = (segmentTable.length - segmentTableSize) * 255; diff --git a/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt b/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt index c7e94c7f8..912ab10da 100644 --- a/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt +++ b/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt @@ -186,7 +186,7 @@ object ImageStrategy { fun dbUrlToImageList(url: String?): List { return when (url) { null -> listOf() - else -> listOf(Image(url, -1, -1, ResolutionLevel.UNKNOWN)) + else -> listOf(Image(url, Image.HEIGHT_UNKNOWN, Image.WIDTH_UNKNOWN, ResolutionLevel.UNKNOWN)) } } } diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMission.java b/app/src/main/java/us/shandian/giga/get/DownloadMission.java index 54340ce5d..da963d330 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -1,5 +1,7 @@ package us.shandian.giga.get; +import android.content.Context; +import android.graphics.Bitmap; import android.os.Handler; import android.system.ErrnoException; import android.system.OsConstants; @@ -8,6 +10,7 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.schabi.newpipe.App; import org.schabi.newpipe.DownloaderImpl; import java.io.File; @@ -21,11 +24,18 @@ import java.net.SocketTimeoutException; import java.net.URL; import java.net.UnknownHostException; import java.nio.channels.ClosedByInterruptException; +import java.util.List; import java.util.Objects; import javax.net.ssl.SSLException; +import org.schabi.newpipe.extractor.Image; +import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.streams.io.StoredFileHelper; +import org.schabi.newpipe.util.image.CoilHelper; +import org.schabi.newpipe.util.image.ImageStrategy; +import org.schabi.newpipe.util.image.PreferredImageQuality; + import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.util.Utility; @@ -58,6 +68,10 @@ public class DownloadMission extends Mission { public static final int ERROR_HTTP_NO_CONTENT = 204; static final int ERROR_HTTP_FORBIDDEN = 403; + private StreamInfo streamInfo; + protected volatile Bitmap thumbnail; + protected volatile boolean thumbnailFetched = false; + /** * The urls of the file to download */ @@ -153,7 +167,8 @@ public class DownloadMission extends Mission { public transient Thread[] threads = new Thread[0]; public transient Thread init = null; - public DownloadMission(String[] urls, StoredFileHelper storage, char kind, Postprocessing psInstance) { + public DownloadMission(String[] urls, StoredFileHelper storage, char kind, + Postprocessing psInstance, StreamInfo streamInfo, Context context) { if (Objects.requireNonNull(urls).length < 1) throw new IllegalArgumentException("urls array is empty"); this.urls = urls; @@ -163,6 +178,7 @@ public class DownloadMission extends Mission { this.maxRetry = 3; this.storage = storage; this.psAlgorithm = psInstance; + this.streamInfo = streamInfo; if (DEBUG && psInstance == null && urls.length > 1) { Log.w(TAG, "mission created with multiple urls ¿missing post-processing algorithm?"); @@ -698,6 +714,7 @@ public class DownloadMission extends Mission { Exception exception = null; try { + psAlgorithm.setThumbnail(thumbnail); psAlgorithm.run(this); } catch (Exception err) { Log.e(TAG, "Post-processing failed. " + psAlgorithm.toString(), err); @@ -829,6 +846,34 @@ public class DownloadMission extends Mission { } } + /** + * Loads the thumbnail / cover art from a list of thumbnails. + * The highest quality is selected. + * + * @param images the list of thumbnails + */ + public void fetchThumbnail(@NonNull final List images) { + if (images.isEmpty()) { + thumbnailFetched = true; + return; + } + + try { + // Some containers have a limited size for embedded images / metadata. + // To avoid problems, we download a medium quality image. + // Alternative approaches are to either downscale a high res image or + // to download the correct size depending on the chosen post-processing algorithm. + final String thumbnailUrl = ImageStrategy.choosePreferredImage( + images, PreferredImageQuality.MEDIUM); + // TODO: get context from somewhere else + thumbnail = CoilHelper.INSTANCE.loadBitmapBlocking(App.getInstance(), thumbnailUrl); + thumbnailFetched = true; + } catch (final Exception e) { + Log.w(TAG, "fetchThumbnail: failed to load thumbnail", e); + thumbnailFetched = true; + return; + } + } static class HttpError extends Exception { final int statusCode; diff --git a/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java index badb5f7ed..f76fadb31 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java @@ -34,7 +34,8 @@ class OggFromWebmDemuxer extends Postprocessing { @Override int process(SharpStream out, @NonNull SharpStream... sources) throws IOException { - OggFromWebMWriter demuxer = new OggFromWebMWriter(sources[0], out, streamInfo); + OggFromWebMWriter demuxer = new OggFromWebMWriter( + sources[0], out, streamInfo, thumbnail); demuxer.parseSource(); demuxer.selectTrack(0); demuxer.build(); diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java index 1c9143252..c6161f19e 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java @@ -1,5 +1,6 @@ package us.shandian.giga.postprocessing; +import android.graphics.Bitmap; import android.util.Log; import androidx.annotation.NonNull; @@ -80,6 +81,7 @@ public abstract class Postprocessing implements Serializable { private String[] args; protected StreamInfo streamInfo; + protected Bitmap thumbnail; private transient DownloadMission mission; @@ -107,6 +109,10 @@ public abstract class Postprocessing implements Serializable { } } + public void setThumbnail(Bitmap thumbnail) { + this.thumbnail = thumbnail; + } + public void run(DownloadMission target) throws IOException { this.mission = target; diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java index 76da18b2d..e4f4ee6f5 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -40,6 +40,7 @@ import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; import org.schabi.newpipe.download.DownloadActivity; +import org.schabi.newpipe.extractor.Image; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.player.helper.LockManager; import org.schabi.newpipe.streams.io.StoredDirectoryHelper; @@ -408,7 +409,8 @@ public class DownloadManagerService extends Service { else ps = Postprocessing.getAlgorithm(psName, psArgs, streamInfo); - final DownloadMission mission = new DownloadMission(urls, storage, kind, ps); + final DownloadMission mission = new DownloadMission( + urls, storage, kind, ps, streamInfo, getApplicationContext()); mission.threadCount = threads; mission.source = streamInfo.getUrl(); mission.nearLength = nearLength; @@ -417,7 +419,18 @@ public class DownloadManagerService extends Service { if (ps != null) ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this)); - handleConnectivityState(true);// first check the actual network status + if (streamInfo != null) { + new Thread(() -> { + try { + mission.fetchThumbnail(streamInfo.getThumbnails()); + } catch (Exception e) { + Log.w(TAG, "failed to fetch thumbnail for mission: " + + mission.storage.getName(), e); + } + }, "ThumbnailFetcher").start(); + } + + handleConnectivityState(true); // first check the actual network status mManager.startMission(mission); }