Add support for cover art / thumbnail for ogg downloads

This commit is contained in:
tobigr 2026-01-03 20:17:41 +01:00
parent e005333ada
commit e0a1011cd6
6 changed files with 143 additions and 9 deletions

View File

@ -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.
*
* <p>
* One could also use the COVERART tag instead, but it is not as widely supported
* as METADATA_BLOCK_PICTURE.
* </p>
*
* @param bitmap The bitmap to use as cover art
* @return The key-value pair representing the tag
*/
private static Pair<String, String> 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.
* <p>
@ -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;

View File

@ -186,7 +186,7 @@ object ImageStrategy {
fun dbUrlToImageList(url: String?): List<Image> {
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))
}
}
}

View File

@ -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<Image> 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;

View File

@ -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();

View File

@ -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;

View File

@ -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);
}