From e005333ada4845e0da07bf4e90886af8625b50f5 Mon Sep 17 00:00:00 2001 From: tobigr Date: Sat, 3 Jan 2026 20:17:34 +0100 Subject: [PATCH 01/17] Clean up --- .../java/org/schabi/newpipe/streams/OggFromWebMWriter.java | 4 +--- 1 file changed, 1 insertion(+), 3 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 7cdc84e22..db11d7c9a 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java @@ -303,9 +303,7 @@ public class OggFromWebMWriter implements Closeable { if (DEBUG) { Log.d("OggFromWebMWriter", "Creating metadata header with this data:"); - metadata.forEach(p -> { - Log.d("OggFromWebMWriter", p.first + "=" + p.second); - }); + metadata.forEach(p -> Log.d("OggFromWebMWriter", p.first + "=" + p.second)); } return makeOpusTagsHeader(metadata); From e0a1011cd63a0c86753b4b4214f0631e4fd33e9d Mon Sep 17 00:00:00 2001 From: tobigr Date: Sat, 3 Jan 2026 20:17:41 +0100 Subject: [PATCH 02/17] 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); } From bacbe3f8477c2b17c06e6da1b9065a4e2b3e4158 Mon Sep 17 00:00:00 2001 From: tobigr Date: Sat, 3 Jan 2026 16:00:35 +0100 Subject: [PATCH 03/17] Add metadata fields to MP4 downloads --- .../newpipe/streams/Mp4FromDashWriter.java | 119 +++++++++++++++++- .../giga/postprocessing/M4aNoDash.java | 2 +- .../giga/postprocessing/Mp4FromDashMuxer.java | 2 +- 3 files changed, 118 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java index 807f190b4..ada402d8c 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java @@ -1,5 +1,6 @@ package org.schabi.newpipe.streams; +import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.streams.Mp4DashReader.Hdlr; import org.schabi.newpipe.streams.Mp4DashReader.Mdia; import org.schabi.newpipe.streams.Mp4DashReader.Mp4DashChunk; @@ -11,6 +12,7 @@ import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; /** @@ -50,13 +52,17 @@ public class Mp4FromDashWriter { private final ArrayList compatibleBrands = new ArrayList<>(5); - public Mp4FromDashWriter(final SharpStream... sources) throws IOException { + private final StreamInfo streamInfo; + + public Mp4FromDashWriter(final StreamInfo streamInfo, + final SharpStream... sources) throws IOException { for (final SharpStream src : sources) { if (!src.canRewind() && !src.canRead()) { throw new IOException("All sources must be readable and allow rewind"); } } + this.streamInfo = streamInfo; sourceTracks = sources; readers = new Mp4DashReader[sourceTracks.length]; readersChunks = new Mp4DashChunk[readers.length]; @@ -712,10 +718,12 @@ public class Mp4FromDashWriter { makeMvhd(longestTrack); + makeUdta(); + for (int i = 0; i < tracks.length; i++) { if (tracks[i].trak.tkhd.matrix.length != 36) { - throw - new RuntimeException("bad track matrix length (expected 36) in track n°" + i); + throw new RuntimeException( + "bad track matrix length (expected 36) in track n°" + i); } makeTrak(i, durations[i], defaultMediaTime[i], tablesInfo[i], is64); } @@ -898,6 +906,111 @@ public class Mp4FromDashWriter { return buffer.array(); } + + /** + * Create the 'udta' box with metadata fields. + * @throws IOException + */ + private void makeUdta() throws IOException { + if (streamInfo == null) { + return; + } + + final String title = streamInfo.getName(); + final String artist = streamInfo.getUploaderName(); + final String date = streamInfo.getUploadDate().getLocalDateTime().toLocalDate().toString(); + + // udta + final int startUdta = auxOffset(); + auxWrite(ByteBuffer.allocate(8).putInt(0).putInt(0x75647461).array()); // "udta" + + // meta (full box: type + version/flags) + final int startMeta = auxOffset(); + auxWrite(ByteBuffer.allocate(8).putInt(0).putInt(0x6D657461).array()); // "meta" + auxWrite(ByteBuffer.allocate(4).putInt(0).array()); // version & flags = 0 + + // hdlr inside meta + auxWrite(makeMetaHdlr()); + + // ilst container + final int startIlst = auxOffset(); + auxWrite(ByteBuffer.allocate(8).putInt(0).putInt(0x696C7374).array()); // "ilst" + + if (title != null && !title.isEmpty()) { + writeMetaItem("©nam", title); + } + if (artist != null && !artist.isEmpty()) { + writeMetaItem("©ART", artist); + } + if (date != null && !date.isEmpty()) { + writeMetaItem("©day", date); + } + + // fix lengths + lengthFor(startIlst); + lengthFor(startMeta); + lengthFor(startUdta); + } + + /** + * Helper to write a metadata item inside the 'ilst' box. + * + *

+     *     [size][key] [data_box]
+     *     data_box = [size]["data"][type(4bytes)=1][locale(4bytes)=0][payload]
+     * 
+ * + * @param keyStr 4-char metadata key + * @param value the metadata value + * @throws IOException + */ + // + private void writeMetaItem(final String keyStr, final String value) throws IOException { + final byte[] valBytes = value.getBytes(StandardCharsets.UTF_8); + final byte[] keyBytes = keyStr.getBytes(StandardCharsets.ISO_8859_1); + + final int dataBoxSize = 16 + valBytes.length; // 4(size)+4("data")+4(type/locale)+payload + final int itemBoxSize = 8 + dataBoxSize; // 4(size)+4(key)+dataBox + + final ByteBuffer buf = ByteBuffer.allocate(itemBoxSize); + buf.putInt(itemBoxSize); + // key (4 bytes) + if (keyBytes.length == 4) { + buf.put(keyBytes); + } else { + // fallback: pad or truncate + final byte[] kb = new byte[4]; + System.arraycopy(keyBytes, 0, kb, 0, Math.min(keyBytes.length, 4)); + buf.put(kb); + } + + // data box + buf.putInt(dataBoxSize); + buf.putInt(0x64617461); // "data" + buf.putInt(0x00000001); // well-known type indicator (UTF-8) + buf.putInt(0x00000000); // locale + buf.put(valBytes); + + auxWrite(buf.array()); + } + + /** + * Create a minimal hdlr box for the meta container. + * The boxsize is fixed (33 bytes) as no name is provided. + */ + private byte[] makeMetaHdlr() { + + final ByteBuffer buf = ByteBuffer.allocate(33); + buf.putInt(33); + buf.putInt(0x68646C72); // "hdlr" + buf.putInt(0x00000000); // pre-defined + buf.putInt(0x6D646972); // "mdir" handler_type (metadata directory) + buf.putInt(0x00000000); // subtype / reserved + buf.put(new byte[12]); // reserved + buf.put((byte) 0x00); // name (empty, null-terminated) + return buf.array(); + } + static class TablesInfo { int stts; int stsc; diff --git a/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java b/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java index aa5170908..ec59b93a7 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java @@ -30,7 +30,7 @@ class M4aNoDash extends Postprocessing { @Override int process(SharpStream out, SharpStream... sources) throws IOException { - Mp4FromDashWriter muxer = new Mp4FromDashWriter(sources[0]); + Mp4FromDashWriter muxer = new Mp4FromDashWriter(this.streamInfo, sources[0]); muxer.setMainBrand(0x4D344120);// binary string "M4A " muxer.parseSources(); muxer.selectTracks(0); diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java index 74cb43116..9f530ffc9 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java @@ -16,7 +16,7 @@ class Mp4FromDashMuxer extends Postprocessing { @Override int process(SharpStream out, SharpStream... sources) throws IOException { - Mp4FromDashWriter muxer = new Mp4FromDashWriter(sources); + Mp4FromDashWriter muxer = new Mp4FromDashWriter(this.streamInfo, sources); muxer.parseSources(); muxer.selectTracks(0, 0); muxer.build(out); From a91161525a5fc9472a6d488cf4e83e9292626116 Mon Sep 17 00:00:00 2001 From: tobigr Date: Sat, 3 Jan 2026 21:15:14 +0100 Subject: [PATCH 04/17] Add thumbnail / cover art to mp4 downloads --- .../newpipe/streams/Mp4FromDashWriter.java | 73 ++++++++++++++++++- .../giga/postprocessing/M4aNoDash.java | 3 +- .../giga/postprocessing/Mp4FromDashMuxer.java | 2 +- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java index ada402d8c..fd394c47e 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.streams; +import android.graphics.Bitmap; + import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.streams.Mp4DashReader.Hdlr; import org.schabi.newpipe.streams.Mp4DashReader.Mdia; @@ -10,13 +12,20 @@ import org.schabi.newpipe.streams.Mp4DashReader.TrackKind; import org.schabi.newpipe.streams.Mp4DashReader.TrunEntry; import org.schabi.newpipe.streams.io.SharpStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.ArrayList; /** + * MP4 muxer that builds a standard MP4 file from DASH fragmented MP4 sources. + * * @author kapodamy + * + * @implNote See + * https://atomicparsley.sourceforge.net/mpeg-4files.html for information on + * the MP4 file format and its specification. */ public class Mp4FromDashWriter { private static final int EPOCH_OFFSET = 2082844800; @@ -53,8 +62,10 @@ public class Mp4FromDashWriter { private final ArrayList compatibleBrands = new ArrayList<>(5); private final StreamInfo streamInfo; + private final Bitmap thumbnail; public Mp4FromDashWriter(final StreamInfo streamInfo, + final Bitmap thumbnail, final SharpStream... sources) throws IOException { for (final SharpStream src : sources) { if (!src.canRewind() && !src.canRead()) { @@ -63,6 +74,7 @@ public class Mp4FromDashWriter { } this.streamInfo = streamInfo; + this.thumbnail = thumbnail; sourceTracks = sources; readers = new Mp4DashReader[sourceTracks.length]; readersChunks = new Mp4DashChunk[readers.length]; @@ -946,10 +958,23 @@ public class Mp4FromDashWriter { writeMetaItem("©day", date); } + + + if (thumbnail != null) { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + thumbnail.compress(Bitmap.CompressFormat.PNG, 100, baos); + final byte[] imgBytes = baos.toByteArray(); + baos.close(); + // 0x0000000E = PNG type indicator for 'data' box (0x0D = JPEG) + writeMetaCover(imgBytes, 0x0000000E); + + } + // fix lengths lengthFor(startIlst); lengthFor(startMeta); lengthFor(startUdta); + } /** @@ -997,9 +1022,9 @@ public class Mp4FromDashWriter { /** * Create a minimal hdlr box for the meta container. * The boxsize is fixed (33 bytes) as no name is provided. + * @return byte array with the hdlr box */ private byte[] makeMetaHdlr() { - final ByteBuffer buf = ByteBuffer.allocate(33); buf.putInt(33); buf.putInt(0x68646C72); // "hdlr" @@ -1011,6 +1036,52 @@ public class Mp4FromDashWriter { return buf.array(); } + /** + * Helper to write cover image inside the 'udta' box. + * + *
+     *     [size][key] [data_box]
+     *     data_box = [size]["data"][type(4bytes)][locale(4bytes)=0][payload]
+     * 
+ * + * @param imageData image byte data + * @param dataType type indicator: 0x0000000E = PNG, 0x0000000D = JPEG + * @throws IOException + */ + private void writeMetaCover(final byte[] imageData, final int dataType) throws IOException { + if (imageData == null || imageData.length == 0) { + return; + } + + final byte[] keyBytes = "covr".getBytes(StandardCharsets.ISO_8859_1); + + // data box: 4(size) + 4("data") + 4(type) + 4(locale) + payload + final int dataBoxSize = 16 + imageData.length; + final int itemBoxSize = 8 + dataBoxSize; + + final ByteBuffer buf = ByteBuffer.allocate(itemBoxSize); + buf.putInt(itemBoxSize); + + // key (4 chars) + if (keyBytes.length == 4) { + buf.put(keyBytes); + } else { + final byte[] kb = new byte[4]; + System.arraycopy(keyBytes, 0, kb, 0, Math.min(keyBytes.length, 4)); + buf.put(kb); + } + + // data box + buf.putInt(dataBoxSize); + buf.putInt(0x64617461); // "data" + buf.putInt(dataType); // type indicator: 0x0000000E = PNG, 0x0000000D = JPEG + buf.putInt(0x00000000); // locale + buf.put(imageData); + + auxWrite(buf.array()); + } + + static class TablesInfo { int stts; int stsc; diff --git a/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java b/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java index ec59b93a7..ec57ed491 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java @@ -30,7 +30,8 @@ class M4aNoDash extends Postprocessing { @Override int process(SharpStream out, SharpStream... sources) throws IOException { - Mp4FromDashWriter muxer = new Mp4FromDashWriter(this.streamInfo, sources[0]); + Mp4FromDashWriter muxer = new Mp4FromDashWriter( + this.streamInfo, this.thumbnail, sources[0]); muxer.setMainBrand(0x4D344120);// binary string "M4A " muxer.parseSources(); muxer.selectTracks(0); diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java index 9f530ffc9..887ba1bf5 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java @@ -16,7 +16,7 @@ class Mp4FromDashMuxer extends Postprocessing { @Override int process(SharpStream out, SharpStream... sources) throws IOException { - Mp4FromDashWriter muxer = new Mp4FromDashWriter(this.streamInfo, sources); + Mp4FromDashWriter muxer = new Mp4FromDashWriter(this.streamInfo, this.thumbnail, sources); muxer.parseSources(); muxer.selectTracks(0, 0); muxer.build(out); From cb71440619b0f5742bb70cc15bfbe4cf9333be2e Mon Sep 17 00:00:00 2001 From: tobigr Date: Fri, 9 Jan 2026 03:02:24 +0100 Subject: [PATCH 05/17] Add documentation and replace magic numbers by constants --- .../newpipe/streams/Mp4FromDashWriter.java | 61 ++++--- .../newpipe/streams/OggFromWebMWriter.java | 160 +++++++++++++----- .../giga/postprocessing/Postprocessing.java | 14 +- 3 files changed, 171 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java index fd394c47e..87703e218 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java @@ -21,11 +21,13 @@ import java.util.ArrayList; /** * MP4 muxer that builds a standard MP4 file from DASH fragmented MP4 sources. * - * @author kapodamy + *

+ * See + * https://atomicparsley.sourceforge.net/mpeg-4files.html for information on + * the MP4 file format and its specification. + *

* - * @implNote See - * https://atomicparsley.sourceforge.net/mpeg-4files.html for information on - * the MP4 file format and its specification. + * @author kapodamy */ public class Mp4FromDashWriter { private static final int EPOCH_OFFSET = 2082844800; @@ -783,7 +785,7 @@ public class Mp4FromDashWriter { final int mediaTime; if (tracks[index].trak.edstElst == null) { - // is a audio track ¿is edst/elst optional for audio tracks? + // is an audio track; is edst/elst optional for audio tracks? mediaTime = 0x00; // ffmpeg set this value as zero, instead of defaultMediaTime bMediaRate = 0x00010000; } else { @@ -891,28 +893,35 @@ public class Mp4FromDashWriter { return offset + 0x14; } + /** + * Creates a Sample Group Description Box. + * + *

+ * What does it do? + *
+ * The table inside of this box gives information about the + * characteristics of sample groups. The descriptive information is any other + * information needed to define or characterize the sample group. + *

+ * + *

+ * ¿is replicable this box? + *
+ * NO due lacks of documentation about this box but... + * most of m4a encoders and ffmpeg uses this box with dummy values (same values) + *

+ * + * @return byte array with the 'sgpd' box + */ private byte[] makeSgpd() { - /* - * Sample Group Description Box - * - * ¿whats does? - * the table inside of this box gives information about the - * characteristics of sample groups. The descriptive information is any other - * information needed to define or characterize the sample group. - * - * ¿is replicable this box? - * NO due lacks of documentation about this box but... - * most of m4a encoders and ffmpeg uses this box with dummy values (same values) - */ - final ByteBuffer buffer = ByteBuffer.wrap(new byte[] { 0x00, 0x00, 0x00, 0x1A, // box size 0x73, 0x67, 0x70, 0x64, // "sgpd" 0x01, 0x00, 0x00, 0x00, // box flags (unknown flag sets) - 0x72, 0x6F, 0x6C, 0x6C, // ¿¿group type?? - 0x00, 0x00, 0x00, 0x02, // ¿¿?? - 0x00, 0x00, 0x00, 0x01, // ¿¿?? - (byte) 0xFF, (byte) 0xFF // ¿¿?? + 0x72, 0x6F, 0x6C, 0x6C, // group type?? + 0x00, 0x00, 0x00, 0x02, // ?? + 0x00, 0x00, 0x00, 0x01, // ?? + (byte) 0xFF, (byte) 0xFF // ?? }); return buffer.array(); @@ -955,6 +964,7 @@ public class Mp4FromDashWriter { writeMetaItem("©ART", artist); } if (date != null && !date.isEmpty()) { + // this means 'year' in mp4 metadata, who the hell thought that? writeMetaItem("©day", date); } @@ -1037,8 +1047,11 @@ public class Mp4FromDashWriter { } /** - * Helper to write cover image inside the 'udta' box. - * + * Helper to add cover image inside the 'udta' box. + *

+ * This method writes the 'covr' metadata item which contains the cover image. + * The cover image is displayed as thumbnail in many media players and file managers. + *

*
      *     [size][key] [data_box]
      *     data_box = [size]["data"][type(4bytes)][locale(4bytes)=0][payload]
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 1b597a711..e2ee364cc 100644
--- a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java
+++ b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java
@@ -29,18 +29,64 @@ import java.util.List;
 import java.util.stream.Collectors;
 
 /**
+ * 

+ * This class is used to convert a WebM stream containing Opus or Vorbis audio + * into an Ogg stream. + *

+ * + *

+ * The following specifications are used for the implementation: + *

+ * + * * @author kapodamy + * @author tobigr */ public class OggFromWebMWriter implements Closeable { + private static final String TAG = OggFromWebMWriter.class.getSimpleName(); + + /** + * No flags set. + */ private static final byte FLAG_UNSET = 0x00; - //private static final byte FLAG_CONTINUED = 0x01; + /** + * The packet is continued from previous the previous page. + */ + private static final byte FLAG_CONTINUED = 0x01; + /** + * BOS (beginning of stream). + */ private static final byte FLAG_FIRST = 0x02; - private static final byte FLAG_LAST = 0x04; + /** + * EOS (end of stream). + */ + private static final byte FLAG_LAST = 0x04;; private static final byte HEADER_CHECKSUM_OFFSET = 22; private static final byte HEADER_SIZE = 27; - private static final int TIME_SCALE_NS = 1000000000; + private static final int TIME_SCALE_NS = 1_000_000_000; + + /** + * The maximum size of a segment in the Ogg page, in bytes. + * This is a fixed value defined by the Ogg specification. + */ + private static final int OGG_SEGMENT_SIZE = 255; + + /** + * The maximum size of the Opus packet in bytes, to be included in the Ogg page. + * @see + * RFC7845 6. Packet Size Limits + */ + private static final int OPUS_MAX_PACKETS_PAGE_SIZE = 65_025; private boolean done = false; private boolean parsed = false; @@ -62,7 +108,7 @@ public class OggFromWebMWriter implements Closeable { private long webmBlockNearDuration = 0; private short segmentTableSize = 0; - private final byte[] segmentTable = new byte[255]; + private final byte[] segmentTable = new byte[OGG_SEGMENT_SIZE]; private long segmentTableNextTimestamp = TIME_SCALE_NS; private final int[] crc32Table = new int[256]; @@ -203,16 +249,16 @@ public class OggFromWebMWriter implements Closeable { /* step 2: create packet with code init data */ if (webmTrack.codecPrivate != null) { addPacketSegment(webmTrack.codecPrivate.length); - makePacketheader(0x00, header, webmTrack.codecPrivate); + makePacketHeader(0x00, header, webmTrack.codecPrivate); write(header); output.write(webmTrack.codecPrivate); } /* step 3: create packet with metadata */ - final byte[] buffer = makeMetadata(); + final byte[] buffer = makeCommentHeader(); if (buffer != null) { addPacketSegment(buffer.length); - makePacketheader(0x00, header, buffer); + makePacketHeader(0x00, header, buffer); write(header); output.write(buffer); } @@ -251,7 +297,7 @@ public class OggFromWebMWriter implements Closeable { elapsedNs = Math.ceil(elapsedNs * resolution); // create header and calculate page checksum - int checksum = makePacketheader((long) elapsedNs, header, null); + int checksum = makePacketHeader((long) elapsedNs, header, null); checksum = calcCrc32(checksum, page.array(), page.position()); header.putInt(HEADER_CHECKSUM_OFFSET, checksum); @@ -264,7 +310,7 @@ public class OggFromWebMWriter implements Closeable { } } - private int makePacketheader(final long granPos, @NonNull final ByteBuffer buffer, + private int makePacketHeader(final long granPos, @NonNull final ByteBuffer buffer, final byte[] immediatePage) { short length = HEADER_SIZE; @@ -297,10 +343,24 @@ public class OggFromWebMWriter implements Closeable { return checksumCrc32; } + /** + * Creates the metadata header for the selected codec (Opus or Vorbis). + * + * @see + * RFC7845 5.2. Comment Header for OPUS metadata header format + * @see + * Vorbis I 4.2. Header decode and decode setup and + * + * Vorbis 5. comment field and header specification + * for VORBIS metadata header format + * + * @return the metadata header as a byte array, or null if the codec is not supported + * for metadata generation + */ @Nullable - private byte[] makeMetadata() { + private byte[] makeCommentHeader() { if (DEBUG) { - Log.d("OggFromWebMWriter", "Downloading media with codec ID " + webmTrack.codecId); + Log.d(TAG, "Downloading media with codec ID " + webmTrack.codecId); } if ("A_OPUS".equals(webmTrack.codecId)) { @@ -315,19 +375,22 @@ public class OggFromWebMWriter implements Closeable { .getLocalDateTime() .format(DateTimeFormatter.ISO_DATE))); if (thumbnail != null) { - metadata.add(makeOpusPictureTag(thumbnail)); + metadata.add(makeFlacPictureTag(thumbnail)); } } if (DEBUG) { - Log.d("OggFromWebMWriter", "Creating metadata header with this data:"); - metadata.forEach(p -> Log.d("OggFromWebMWriter", p.first + "=" + p.second)); + Log.d(TAG, "Creating metadata header with this data:"); + metadata.forEach(p -> Log.d(TAG, p.first + "=" + p.second)); } return makeOpusTagsHeader(metadata); } else if ("A_VORBIS".equals(webmTrack.codecId)) { + // See https://xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-610004.2 + // for the Vorbis comment header format + // TODO: add Vorbis metadata: same as Opus, but with the Vorbis comment header format return new byte[]{ - 0x03, // ??? + 0x03, // packet type for Vorbis comment header 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) @@ -358,27 +421,37 @@ public class OggFromWebMWriter implements Closeable { } /** - * Adds the {@code METADATA_BLOCK_PICTURE} tag to the Opus metadata, - * containing the provided bitmap as cover art. + * Generates a FLAC picture block for the provided bitmap. * *

- * One could also use the COVERART tag instead, but it is not as widely supported - * as METADATA_BLOCK_PICTURE. + * The {@code METADATA_BLOCK_PICTURE} tag is defined in the FLAC specification (RFC 9639) + * and is supported by Opus and Vorbis metadata headers. + * The picture block contains the image data which is converted to JPEG + * and associated metadata such as picture type, dimensions, and color depth. + * The image data is Base64-encoded as per specification. *

* - * @param bitmap The bitmap to use as cover art - * @return The key-value pair representing the tag + * @see + * RFC 9639 8.8 Picture + * + * @param bitmap The bitmap to use for the picture block + * @return The key-value pair representing the tag. + * The key is {@code METADATA_BLOCK_PICTURE} + * and the value is the Base64-encoded FLAC picture block. */ - private static Pair makeOpusPictureTag(final Bitmap bitmap) { + private static Pair makeFlacPictureTag(final Bitmap bitmap) { // FLAC picture block format (big-endian): // uint32 picture_type - // uint32 mime_length, mime_string - // uint32 desc_length, desc_string + // 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 + // uint32 data_length, + // data_bytes final ByteArrayOutputStream baos = new ByteArrayOutputStream(); bitmap.compress(Bitmap.CompressFormat.JPEG, 100, baos); @@ -389,7 +462,11 @@ public class OggFromWebMWriter implements Closeable { // 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) + // See https://www.rfc-editor.org/rfc/rfc9639.html#table-13 for the complete list + // of picture types + // TODO: allow specifying other picture types, i.e. cover (front) for music albums; + // but this info needs to be provided by the extractor first. + buf.putInt(3); // picture type: 0 = Other, 2 = Cover (front) buf.putInt(mimeBytes.length); buf.put(mimeBytes); buf.putInt(descBytes.length); @@ -397,10 +474,10 @@ public class OggFromWebMWriter implements Closeable { 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(bitmap.getWidth()); + buf.putInt(bitmap.getHeight()); + buf.putInt(24); // color depth for JPEG and PNG is usually 24 bits + buf.putInt(0); // colors indexed (0 for non-indexed images like JPEG) buf.putInt(imageData.length); buf.put(imageData); final String b64 = Base64.getEncoder().encodeToString(buf.array()); @@ -413,6 +490,9 @@ public class OggFromWebMWriter implements Closeable { * You probably want to use makeOpusMetadata(), which uses this function to create * a header with sensible metadata filled in. * + * @ImplNote See + * RFC7845 5.2 + * * @param keyValueLines A list of pairs of the tags. This can also be though of as a mapping * from one key to multiple values. * @return The binary header @@ -431,6 +511,7 @@ public class OggFromWebMWriter implements Closeable { final var head = ByteBuffer.allocate(byteCount); head.order(ByteOrder.LITTLE_ENDIAN); + // See RFC7845 5.2: https://datatracker.ietf.org/doc/html/rfc7845.html#section-5.2 head.put(new byte[]{ 0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string 0x00, 0x00, 0x00, 0x00, // vendor (aka. Encoder) string of length 0 @@ -514,18 +595,19 @@ public class OggFromWebMWriter implements Closeable { } private boolean addPacketSegment(final int size) { - if (size > 65025) { - throw new UnsupportedOperationException( - String.format("page size is %s but cannot be larger than 65025", size)); + if (size > OPUS_MAX_PACKETS_PAGE_SIZE) { + throw new UnsupportedOperationException(String.format( + "page size is %s but cannot be larger than %s", + size, OPUS_MAX_PACKETS_PAGE_SIZE)); } - int available = (segmentTable.length - segmentTableSize) * 255; - final boolean extra = (size % 255) == 0; + int available = (segmentTable.length - segmentTableSize) * OGG_SEGMENT_SIZE; + final boolean extra = (size % OGG_SEGMENT_SIZE) == 0; if (extra) { // add a zero byte entry in the table - // required to indicate the sample size is multiple of 255 - available -= 255; + // required to indicate the sample size is multiple of OGG_SEGMENT_SIZE + available -= OGG_SEGMENT_SIZE; } // check if possible add the segment, without overflow the table @@ -533,8 +615,8 @@ public class OggFromWebMWriter implements Closeable { return false; // not enough space on the page } - for (int seg = size; seg > 0; seg -= 255) { - segmentTable[segmentTableSize++] = (byte) Math.min(seg, 255); + for (int seg = size; seg > 0; seg -= OGG_SEGMENT_SIZE) { + segmentTable[segmentTableSize++] = (byte) Math.min(seg, OGG_SEGMENT_SIZE); } if (extra) { 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 c6161f19e..af43896f7 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java @@ -4,6 +4,7 @@ import android.graphics.Bitmap; import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.streams.io.SharpStream; @@ -33,7 +34,7 @@ public abstract class Postprocessing implements Serializable { public transient static final String ALGORITHM_OGG_FROM_WEBM_DEMUXER = "webm-ogg-d"; public static Postprocessing getAlgorithm(@NonNull String algorithmName, String[] args, - StreamInfo streamInfo) { + @NonNull StreamInfo streamInfo) { Postprocessing instance; switch (algorithmName) { @@ -80,7 +81,18 @@ public abstract class Postprocessing implements Serializable { private final String name; private String[] args; + + /** + * StreamInfo object related to the current download + */ + @NonNull protected StreamInfo streamInfo; + + /** + * The thumbnail / cover art bitmap associated with the current download. + * May be null. + */ + @Nullable protected Bitmap thumbnail; private transient DownloadMission mission; From ec6243d7ee539f3a8c5aca9c3d26213d225d6f15 Mon Sep 17 00:00:00 2001 From: TobiGr Date: Fri, 6 Feb 2026 21:56:18 +0100 Subject: [PATCH 06/17] Span metadata about multiple pages if needed --- .../newpipe/streams/OggFromWebMWriter.java | 104 +++++++++++++++++- 1 file changed, 99 insertions(+), 5 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 e2ee364cc..64105f68d 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java @@ -27,6 +27,7 @@ import java.util.ArrayList; import java.util.Base64; import java.util.List; import java.util.stream.Collectors; +import java.util.Arrays; /** *

@@ -68,7 +69,7 @@ public class OggFromWebMWriter implements Closeable { /** * EOS (end of stream). */ - private static final byte FLAG_LAST = 0x04;; + private static final byte FLAG_LAST = 0x04; private static final byte HEADER_CHECKSUM_OFFSET = 22; private static final byte HEADER_SIZE = 27; @@ -257,10 +258,7 @@ public class OggFromWebMWriter implements Closeable { /* step 3: create packet with metadata */ final byte[] buffer = makeCommentHeader(); if (buffer != null) { - addPacketSegment(buffer.length); - makePacketHeader(0x00, header, buffer); - write(header); - output.write(buffer); + addPacketSegmentMultiPage(buffer, header); } /* step 4: calculate amount of packets */ @@ -626,6 +624,102 @@ public class OggFromWebMWriter implements Closeable { return true; } + /** + * Like {@link #addPacketSegment(SimpleBlock)} for large metadata blobs + * splits the provided data into multiple pages if necessary + * and writes them immediately (header + data). + * This method is intended to be used only for metadata (e.g. large thumbnails). + * + * @param data the metadata to add as a packet segment + * @param header a reusable ByteBuffer for writing page headers; this method will write + * the header for each page as needed + */ + private void addPacketSegmentMultiPage(@NonNull final byte[] data, + @NonNull final ByteBuffer header) throws IOException { + int offset = 0; + boolean first = true; + + while (offset < data.length) { + final int remaining = data.length - offset; + final boolean finalChunkCandidate = remaining <= OPUS_MAX_PACKETS_PAGE_SIZE; + final int chunkSize; + if (finalChunkCandidate) { + chunkSize = remaining; // final chunk can be any size + } else { + // For intermediate (non-final) chunks, make the chunk size a multiple + // of OGG_SEGMENT_SIZE so that the last lacing value is 255 and the + // decoder won't treat the packet as finished on that page. + final int maxFullSegments = OPUS_MAX_PACKETS_PAGE_SIZE / OGG_SEGMENT_SIZE; + chunkSize = maxFullSegments * OGG_SEGMENT_SIZE; + } + + final boolean isFinalChunk = (offset + chunkSize) >= data.length; + + // We must reserve appropriate number of lacing values in the segment table. + // For chunks that are exact multiples of OGG_SEGMENT_SIZE and are the final + // chunk of the packet, a trailing 0 lacing entry is required to indicate + // the packet ends exactly on a segment boundary. For intermediate chunks + // (continued across pages) we MUST NOT write that trailing 0 because then + // the packet would appear complete on that page. Instead intermediate + // chunks should end with only 255-valued lacing entries (no trailing 0). + final int fullSegments = chunkSize / OGG_SEGMENT_SIZE; // may be 0 + final int lastSegSize = chunkSize % OGG_SEGMENT_SIZE; // 0..254 + final boolean chunkIsMultiple = (lastSegSize == 0); + + int requiredEntries = fullSegments + (lastSegSize > 0 ? 1 : 0); + if (chunkIsMultiple && isFinalChunk) { + // need an extra zero entry to mark packet end + requiredEntries += 1; + } + + // If the segment table doesn't have enough room, flush the current page + // by writing a header without immediate data. This clears the segment table. + if (requiredEntries > (segmentTable.length - segmentTableSize)) { + // flush current page + int checksum = makePacketHeader(0x00, header, null); + checksum = calcCrc32(checksum, new byte[0], 0); + header.putInt(HEADER_CHECKSUM_OFFSET, checksum); + write(header); + } + + // After ensuring space, if still not enough (edge case), throw + if (requiredEntries > (segmentTable.length - segmentTableSize)) { + throw new IOException("Unable to reserve segment table entries for metadata chunk"); + } + + // Fill the segment table entries for this chunk. For intermediate chunks + // that are an exact multiple of OGG_SEGMENT_SIZE we must NOT append a + // trailing zero entry (that would incorrectly signal packet end). + final int remainingToAssign = chunkSize; + for (int seg = remainingToAssign; seg > 0; seg -= OGG_SEGMENT_SIZE) { + segmentTable[segmentTableSize++] = (byte) Math.min(seg, OGG_SEGMENT_SIZE); + } + + if (chunkIsMultiple && isFinalChunk) { + // Only append the zero terminator for a final chunk that has an exact + // multiple of OGG_SEGMENT_SIZE bytes. + segmentTable[segmentTableSize++] = 0x00; + } + + // For continuation pages (after the first), mark the page as continued. + if (!first) { + packetFlag = FLAG_CONTINUED; + } + + final byte[] chunk = Arrays.copyOfRange(data, offset, offset + chunkSize); + + // Now create header (which will consume and clear the segment table) and write + // header + chunk data. makePacketHeader will compute checksum including chunk + // when an immediatePage is provided. + makePacketHeader(0x00, header, chunk); + write(header); + output.write(chunk); + + offset += chunkSize; + first = false; + } + } + private void populateCrc32Table() { for (int i = 0; i < 0x100; i++) { int crc = i << 24; From 599f9ca6d39851577e1270057529a1448eb7fcbf Mon Sep 17 00:00:00 2001 From: TobiGr Date: Fri, 6 Feb 2026 23:23:22 +0100 Subject: [PATCH 07/17] Use high quality images --- app/src/main/java/us/shandian/giga/get/DownloadMission.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 da963d330..e3e0c6cab 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -864,7 +864,7 @@ public class DownloadMission extends Mission { // 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); + images, PreferredImageQuality.HIGH); // TODO: get context from somewhere else thumbnail = CoilHelper.INSTANCE.loadBitmapBlocking(App.getInstance(), thumbnailUrl); thumbnailFetched = true; From 69473da531691688810e8b06984c4464a02bdd9a Mon Sep 17 00:00:00 2001 From: TobiGr Date: Fri, 6 Feb 2026 22:44:08 +0100 Subject: [PATCH 08/17] [DescriptionFragment] Fix thumbnail size: width x height height x width was used before which is an uncommon order. --- .../newpipe/fragments/detail/BaseDescriptionFragment.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java index 4789b02e6..bd174a121 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java @@ -216,9 +216,9 @@ public abstract class BaseDescriptionFragment extends BaseFragment { || image.getWidth() != Image.WIDTH_UNKNOWN // if even the resolution level is unknown, ?x? will be shown || image.getEstimatedResolutionLevel() == Image.ResolutionLevel.UNKNOWN) { - urls.append(imageSizeToText(image.getHeight())); - urls.append('x'); urls.append(imageSizeToText(image.getWidth())); + urls.append('x'); + urls.append(imageSizeToText(image.getHeight())); } else { switch (image.getEstimatedResolutionLevel()) { case LOW -> urls.append(getString(R.string.image_quality_low)); From 454bc970c69613347c5ffebeb5207d760baec9e1 Mon Sep 17 00:00:00 2001 From: tobigr Date: Sat, 7 Feb 2026 16:50:37 +0100 Subject: [PATCH 09/17] DO NOT MERGE: Update extractor to local fork for SongMetadata --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ff69ad774..ab6d4ee3b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,7 +65,7 @@ teamnewpipe-nanojson = "e9d656ddb49a412a5a0a5d5ef20ca7ef09549996" # the corresponding commit hash, since JitPack sometimes deletes artifacts. # If there’s already a git hash, just add more of it to the end (or remove a letter) # to cause jitpack to regenerate the artifact. -teamnewpipe-newpipe-extractor = "v0.25.2" +teamnewpipe-newpipe-extractor = "1799852c25679026e3ff41a4b87993eaf4c748af" webkit = "1.14.0" # Newer versions require minSdk >= 23 work = "2.10.5" # Newer versions require minSdk >= 23 @@ -138,7 +138,7 @@ lisawray-groupie-core = { module = "com.github.lisawray.groupie:groupie", versio lisawray-groupie-viewbinding = { module = "com.github.lisawray.groupie:groupie-viewbinding", version.ref = "groupie" } livefront-bridge = { module = "com.github.livefront:bridge", version.ref = "bridge" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCore" } -newpipe-extractor = { module = "com.github.TeamNewPipe:NewPipeExtractor", version.ref = "teamnewpipe-newpipe-extractor" } +newpipe-extractor = { module = "com.github.tobigr:NewPipeExtractor", version.ref = "teamnewpipe-newpipe-extractor" } newpipe-filepicker = { module = "com.github.TeamNewPipe:NoNonsense-FilePicker", version.ref = "teamnewpipe-filepicker" } newpipe-nanojson = { module = "com.github.TeamNewPipe:nanojson", version.ref = "teamnewpipe-nanojson" } noties-markwon-core = { module = "io.noties.markwon:core", version.ref = "markwon" } From a81149341658b986940720fa2130627c63c20827 Mon Sep 17 00:00:00 2001 From: tobigr Date: Sat, 7 Feb 2026 16:49:45 +0100 Subject: [PATCH 10/17] Add more metadata fields to OGG and MP4 downloads Copyright, record label, album, label, ... --- .../newpipe/streams/Mp4FromDashWriter.java | 31 ++++- .../newpipe/streams/OggFromWebMWriter.java | 115 +++++++++++------- .../newpipe/util/StreamInfoMetadataHelper.kt | 57 +++++++++ 3 files changed, 150 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/org/schabi/newpipe/util/StreamInfoMetadataHelper.kt diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java index 87703e218..7632a4359 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java @@ -11,6 +11,7 @@ import org.schabi.newpipe.streams.Mp4DashReader.Mp4Track; import org.schabi.newpipe.streams.Mp4DashReader.TrackKind; import org.schabi.newpipe.streams.Mp4DashReader.TrunEntry; import org.schabi.newpipe.streams.io.SharpStream; +import org.schabi.newpipe.util.StreamInfoMetadataHelper; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -930,6 +931,14 @@ public class Mp4FromDashWriter { /** * Create the 'udta' box with metadata fields. + * {@code udta} is a user data box that can contain various types of metadata, + * including title, artist, date, and cover art. + * @see Apple Quick Time Format Specification for user data atoms + * @see Multimedia Wiki FFmpeg Metadata + * @see atomicparsley docs + * for a short and understandable reference about metadata keys and values * @throws IOException */ private void makeUdta() throws IOException { @@ -937,10 +946,6 @@ public class Mp4FromDashWriter { return; } - final String title = streamInfo.getName(); - final String artist = streamInfo.getUploaderName(); - final String date = streamInfo.getUploadDate().getLocalDateTime().toLocalDate().toString(); - // udta final int startUdta = auxOffset(); auxWrite(ByteBuffer.allocate(8).putInt(0).putInt(0x75647461).array()); // "udta" @@ -957,6 +962,16 @@ public class Mp4FromDashWriter { final int startIlst = auxOffset(); auxWrite(ByteBuffer.allocate(8).putInt(0).putInt(0x696C7374).array()); // "ilst" + // write metadata items + + final var metaHelper = new StreamInfoMetadataHelper(streamInfo); + final String title = metaHelper.getTitle(); + final String artist = metaHelper.getArtist(); + final String date = metaHelper.getReleaseDate().getLocalDateTime() + .toLocalDate().toString(); + final String recordLabel = metaHelper.getRecordLabel(); + final String copyright = metaHelper.getCopyright(); + if (title != null && !title.isEmpty()) { writeMetaItem("©nam", title); } @@ -967,8 +982,12 @@ public class Mp4FromDashWriter { // this means 'year' in mp4 metadata, who the hell thought that? writeMetaItem("©day", date); } - - + if (recordLabel != null && !recordLabel.isEmpty()) { + writeMetaItem("©lab", recordLabel); + } + if (copyright != null && !copyright.isEmpty()) { + writeMetaItem("©cpy", copyright); + } if (thumbnail != null) { final ByteArrayOutputStream baos = new ByteArrayOutputStream(); 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 64105f68d..c043fff87 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java @@ -9,12 +9,14 @@ import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.schabi.newpipe.extractor.stream.SongMetadata; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.streams.WebMReader.Cluster; import org.schabi.newpipe.streams.WebMReader.Segment; import org.schabi.newpipe.streams.WebMReader.SimpleBlock; import org.schabi.newpipe.streams.WebMReader.WebMTrack; import org.schabi.newpipe.streams.io.SharpStream; +import org.schabi.newpipe.util.StreamInfoMetadataHelper; import java.io.ByteArrayOutputStream; import java.io.Closeable; @@ -40,12 +42,18 @@ import java.util.Arrays; *

*
    *
  • FLAC: RFC 9639
  • + *
  • + * Vorbis: Vorbis I. + *
    + * Vorbis uses FLAC picture blocks for embedding cover art in the metadata. + *
  • *
  • Opus: All specs can be found at * https://opus-codec.org/docs/. * RFC7845 * defines the Ogg encapsulation for Opus streams, i.e.the container format and metadata. + *
    + * Opus uses multiple Vorbis I features, e.g. the comment header format for metadata. *
  • - *
  • Vorbis: Vorbis I
  • *
* * @author kapodamy @@ -349,8 +357,8 @@ public class OggFromWebMWriter implements Closeable { * @see * Vorbis I 4.2. Header decode and decode setup and * - * Vorbis 5. comment field and header specification - * for VORBIS metadata header format + * Vorbis I 5. comment field and header specification + * for VORBIS metadata header format. Vorbis I 5. lists all the possible metadata tags. * * @return the metadata header as a byte array, or null if the codec is not supported * for metadata generation @@ -361,38 +369,58 @@ public class OggFromWebMWriter implements Closeable { Log.d(TAG, "Downloading media with codec ID " + webmTrack.codecId); } + final var metadata = new ArrayList>(); + if (streamInfo != null) { + final SongMetadata songMetadata = streamInfo.getSongMetadata(); + final StreamInfoMetadataHelper metadHelper = new StreamInfoMetadataHelper(streamInfo); + // metadata that can be present in the stream info and the song metadata. + // Use the song metadata if available, otherwise fallback to stream info. + metadata.add(Pair.create("COMMENT", streamInfo.getUrl())); + metadata.add(Pair.create("GENRE", metadHelper.getGenre())); + metadata.add(Pair.create("ARTIST", metadHelper.getArtist())); + metadata.add(Pair.create("TITLE", metadHelper.getTitle())); + metadata.add(Pair.create("DATE", metadHelper.getReleaseDate() + .getLocalDateTime() + .format(DateTimeFormatter.ISO_DATE))); + // Additional metadata that is only present in the song metadata + if (songMetadata != null) { + metadata.add(Pair.create("ALBUM", songMetadata.album)); + if (songMetadata.track != SongMetadata.TRACK_UNKNOWN) { + // TRACKNUMBER is suggested in Vorbis spec, + // but TRACK is more commonly used in practice + metadata.add(Pair.create("TRACKNUMBER", String.valueOf(songMetadata.track))); + metadata.add(Pair.create("TRACK", String.valueOf(songMetadata.track))); + } + metadata.add(Pair.create("PERFORMER", String.join(", ", songMetadata.performer))); + metadata.add(Pair.create("ORGANIZATION", songMetadata.label)); + metadata.add(Pair.create("COPYRIGHT", songMetadata.copyright)); + } + // Add thumbnail as cover art at the end because it is the largest metadata entry + if (thumbnail != null) { + metadata.add(makeFlacPictureTag(thumbnail)); + } + } + + if (DEBUG) { + Log.d(TAG, "Creating metadata header with this data:"); + metadata.forEach(p -> Log.d(TAG, p.first + "=" + p.second)); + } + if ("A_OPUS".equals(webmTrack.codecId)) { - final var metadata = new ArrayList>(); - if (streamInfo != null) { - metadata.add(Pair.create("COMMENT", streamInfo.getUrl())); - metadata.add(Pair.create("GENRE", streamInfo.getCategory())); - metadata.add(Pair.create("ARTIST", streamInfo.getUploaderName())); - metadata.add(Pair.create("TITLE", streamInfo.getName())); - metadata.add(Pair.create("DATE", streamInfo - .getUploadDate() - .getLocalDateTime() - .format(DateTimeFormatter.ISO_DATE))); - if (thumbnail != null) { - metadata.add(makeFlacPictureTag(thumbnail)); - } - } - - if (DEBUG) { - Log.d(TAG, "Creating metadata header with this data:"); - metadata.forEach(p -> Log.d(TAG, p.first + "=" + p.second)); - } - - return makeOpusTagsHeader(metadata); + // See RFC7845 5.2: https://datatracker.ietf.org/doc/html/rfc7845.html#section-5.2 + final byte[] identificationHeader = new byte[]{ + 0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string + 0x00, 0x00, 0x00, 0x00, // vendor (aka. Encoder) string of length 0 + }; + return makeCommentHeader(metadata, identificationHeader); } else if ("A_VORBIS".equals(webmTrack.codecId)) { // See https://xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-610004.2 - // for the Vorbis comment header format - // TODO: add Vorbis metadata: same as Opus, but with the Vorbis comment header format - return new byte[]{ + final byte[] identificationHeader = new byte[]{ 0x03, // packet type for Vorbis comment header 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) + 0x00, 0x00, 0x00, 0x00, // vendor (aka. Encoder) string of length 0 }; + return makeCommentHeader(metadata, identificationHeader); } // not implemented for the desired codec @@ -402,12 +430,12 @@ public class OggFromWebMWriter implements Closeable { /** * This creates a single metadata tag for use in opus metadata headers. It contains the four * byte string length field and includes the string as-is. This cannot be used independently, - * but must follow a proper "OpusTags" header. + * but must follow a proper Comment header. * * @param pair A key-value pair in the format "KEY=some value" * @return The binary data of the encoded metadata tag */ - private static byte[] makeOpusMetadataTag(final Pair pair) { + private static byte[] makeVorbisMetadataTag(final Pair pair) { final var keyValue = pair.first.toUpperCase() + "=" + pair.second.trim(); final var bytes = keyValue.getBytes(); @@ -483,24 +511,21 @@ public class OggFromWebMWriter implements Closeable { } /** - * This returns a complete "OpusTags" header, created from the provided metadata tags. - *

- * You probably want to use makeOpusMetadata(), which uses this function to create - * a header with sensible metadata filled in. - * - * @ImplNote See - * RFC7845 5.2 + * This returns a complete Comment header, created from the provided metadata tags. * * @param keyValueLines A list of pairs of the tags. This can also be though of as a mapping * from one key to multiple values. + * @param identificationHeader the identification header for the codec, + * which is required to be prefixed to the comment header. * @return The binary header */ - private static byte[] makeOpusTagsHeader(final List> keyValueLines) { + private static byte[] makeCommentHeader(final List> keyValueLines, + final byte[] identificationHeader) { final var tags = keyValueLines .stream() - .filter(p -> !p.second.isBlank()) - .map(OggFromWebMWriter::makeOpusMetadataTag) - .collect(Collectors.toUnmodifiableList()); + .filter(p -> p.second != null && !p.second.isBlank()) + .map(OggFromWebMWriter::makeVorbisMetadataTag) + .toList(); final var tagsBytes = tags.stream().collect(Collectors.summingInt(arr -> arr.length)); @@ -509,11 +534,7 @@ public class OggFromWebMWriter implements Closeable { final var head = ByteBuffer.allocate(byteCount); head.order(ByteOrder.LITTLE_ENDIAN); - // See RFC7845 5.2: https://datatracker.ietf.org/doc/html/rfc7845.html#section-5.2 - head.put(new byte[]{ - 0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string - 0x00, 0x00, 0x00, 0x00, // vendor (aka. Encoder) string of length 0 - }); + head.put(identificationHeader); head.putInt(tags.size()); // 4 bytes for tag count tags.forEach(head::put); // dynamic amount of tag bytes diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamInfoMetadataHelper.kt b/app/src/main/java/org/schabi/newpipe/util/StreamInfoMetadataHelper.kt new file mode 100644 index 000000000..3e54ecac2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/StreamInfoMetadataHelper.kt @@ -0,0 +1,57 @@ +package org.schabi.newpipe.util + +import org.schabi.newpipe.extractor.localization.DateWrapper +import org.schabi.newpipe.extractor.stream.SongMetadata +import org.schabi.newpipe.extractor.stream.StreamInfo + +class StreamInfoMetadataHelper( + val streamInfo: StreamInfo +) { + val songInfo: SongMetadata? = streamInfo.songMetadata + + fun getTitle(): String? { + if (songInfo?.title?.contentEquals(streamInfo.name) == true) { + // YT Music uses uppercase chars in the description, but the StreamInfo name is using + // the correct case, so we prefer that + return streamInfo.name + } + return if (songInfo?.title?.isBlank() == false) songInfo.title else streamInfo.name + } + + fun getArtist(): String? { + if (songInfo?.artist?.contentEquals(streamInfo.uploaderName) == true) { + // YT Music uses uppercase chars in the description, but the uploader name is using + // the correct case, so we prefer the uploader name + return streamInfo.uploaderName + } + return if (songInfo?.artist?.isBlank() == false) { + songInfo.artist + } else { + streamInfo.uploaderName + } + } + + fun getPerformer(): List = songInfo?.performer ?: emptyList() + + fun getComposer(): String? = songInfo?.composer + + fun getGenre(): String? = if (songInfo?.genre?.isEmpty() == false) { + songInfo.genre + } else { + streamInfo.category + } + + fun getAlbum(): String? = songInfo?.album + + fun getTrackNumber(): Int? = if (songInfo?.track != SongMetadata.TRACK_UNKNOWN) songInfo?.track else null + + fun getDuration(): Long = songInfo?.duration?.seconds ?: streamInfo.duration + + fun getReleaseDate(): DateWrapper = songInfo?.releaseDate ?: streamInfo.uploadDate + + fun getRecordLabel(): String? = songInfo?.label + + fun getCopyright(): String? = songInfo?.copyright ?: streamInfo.licence + + fun getLocation(): String? = songInfo?.location +} From 69833010125891002a82475d6c2cd9e7d0c44bd1 Mon Sep 17 00:00:00 2001 From: tobigr Date: Sun, 8 Feb 2026 11:02:01 +0100 Subject: [PATCH 11/17] DO NOT MERGE: add download dir to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6b18d08fd..0af4b438c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ bin/ # logs *.log +/downloads/ From 4899651b81c14d8bd2f5435ffc7c20fec0c03ac0 Mon Sep 17 00:00:00 2001 From: tobigr Date: Sun, 8 Feb 2026 12:01:57 +0100 Subject: [PATCH 12/17] Remove transient keyword from static constants --- .../giga/postprocessing/Postprocessing.java | 14 ++++++-------- .../giga/service/DownloadManagerService.java | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) 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 af43896f7..23976e7d6 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java @@ -25,13 +25,13 @@ import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_HOLD; public abstract class Postprocessing implements Serializable { - static transient final byte OK_RESULT = ERROR_NOTHING; + static final byte OK_RESULT = ERROR_NOTHING; - public transient static final String ALGORITHM_TTML_CONVERTER = "ttml"; - public transient static final String ALGORITHM_WEBM_MUXER = "webm"; - public transient static final String ALGORITHM_MP4_FROM_DASH_MUXER = "mp4D-mp4"; - public transient static final String ALGORITHM_M4A_NO_DASH = "mp4D-m4a"; - public transient static final String ALGORITHM_OGG_FROM_WEBM_DEMUXER = "webm-ogg-d"; + public static final String ALGORITHM_TTML_CONVERTER = "ttml"; + public static final String ALGORITHM_WEBM_MUXER = "webm"; + public static final String ALGORITHM_MP4_FROM_DASH_MUXER = "mp4D-mp4"; + public static final String ALGORITHM_M4A_NO_DASH = "mp4D-m4a"; + public static final String ALGORITHM_OGG_FROM_WEBM_DEMUXER = "webm-ogg-d"; public static Postprocessing getAlgorithm(@NonNull String algorithmName, String[] args, @NonNull StreamInfo streamInfo) { @@ -53,8 +53,6 @@ public abstract class Postprocessing implements Serializable { case ALGORITHM_OGG_FROM_WEBM_DEMUXER: instance = new OggFromWebmDemuxer(); break; - /*case "example-algorithm": - instance = new ExampleAlgorithm();*/ default: throw new UnsupportedOperationException("Unimplemented post-processing algorithm: " + algorithmName); } 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 e4f4ee6f5..6ce6a4b38 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -391,7 +391,7 @@ public class DownloadManagerService extends Service { String[] psArgs = intent.getStringArrayExtra(EXTRA_POSTPROCESSING_ARGS); long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0); String tag = intent.getStringExtra(EXTRA_STORAGE_TAG); - StreamInfo streamInfo = (StreamInfo)intent.getSerializableExtra(EXTRA_STREAM_INFO); + StreamInfo streamInfo = (StreamInfo) intent.getSerializableExtra(EXTRA_STREAM_INFO); final var recovery = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_RECOVERY_INFO, MissionRecoveryInfo.class); Objects.requireNonNull(recovery); From 45fae37610af3a38d5966f7a5c2fe146f3a069e0 Mon Sep 17 00:00:00 2001 From: tobigr Date: Sun, 8 Feb 2026 12:15:54 +0100 Subject: [PATCH 13/17] Add metadata to mp4 files that usually would not need postprocessing Extract MP4 metadata (udta) generation into separate helper class --- .../newpipe/download/DownloadDialog.java | 2 + .../newpipe/streams/Mp4FromDashWriter.java | 228 ++------- .../giga/postprocessing/Mp4Metadata.java | 473 ++++++++++++++++++ .../postprocessing/Mp4MetadataHelper.java | 224 +++++++++ .../giga/postprocessing/Postprocessing.java | 4 + 5 files changed, 733 insertions(+), 198 deletions(-) create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/Mp4Metadata.java create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/Mp4MetadataHelper.java diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java index 741bda246..e5153856e 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -1093,6 +1093,8 @@ public class DownloadDialog extends DialogFragment if (secondary.getSizeInBytes() > 0 && videoSize > 0) { nearLength = secondary.getSizeInBytes() + videoSize; } + } else if (selectedStream.getFormat() == MediaFormat.MPEG_4) { + psName = Postprocessing.ALGORITHM_MP4_METADATA; } break; case R.id.subtitle_button: diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java index 7632a4359..73bd51e3d 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java @@ -11,23 +11,22 @@ import org.schabi.newpipe.streams.Mp4DashReader.Mp4Track; import org.schabi.newpipe.streams.Mp4DashReader.TrackKind; import org.schabi.newpipe.streams.Mp4DashReader.TrunEntry; import org.schabi.newpipe.streams.io.SharpStream; -import org.schabi.newpipe.util.StreamInfoMetadataHelper; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import us.shandian.giga.postprocessing.Mp4MetadataHelper; + /** * MP4 muxer that builds a standard MP4 file from DASH fragmented MP4 sources. * - *

- * See - * https://atomicparsley.sourceforge.net/mpeg-4files.html for information on + * @see + * https://atomicparsley.sourceforge.net/mpeg-4files.html for a quick summary on * the MP4 file format and its specification. - *

- * + * @see + * Apple Quick Time Format Specification which is the basis for MP4 file format + * and contains detailed information about the structure of MP4 files. * @author kapodamy */ public class Mp4FromDashWriter { @@ -64,8 +63,8 @@ public class Mp4FromDashWriter { private final ArrayList compatibleBrands = new ArrayList<>(5); - private final StreamInfo streamInfo; - private final Bitmap thumbnail; + + private final Mp4MetadataHelper metadataHelper; public Mp4FromDashWriter(final StreamInfo streamInfo, final Bitmap thumbnail, @@ -76,8 +75,26 @@ public class Mp4FromDashWriter { } } - this.streamInfo = streamInfo; - this.thumbnail = thumbnail; + this.metadataHelper = new Mp4MetadataHelper( + this::auxOffset, + buffer -> { + try { + auxWrite(buffer); + } catch (final IOException e) { + throw new RuntimeException(e); + } + }, + offset -> { + try { + return lengthFor(offset); + } catch (final IOException e) { + throw new RuntimeException(e); + } + }, + streamInfo, + thumbnail + ); + sourceTracks = sources; readers = new Mp4DashReader[sourceTracks.length]; readersChunks = new Mp4DashChunk[readers.length]; @@ -733,7 +750,7 @@ public class Mp4FromDashWriter { makeMvhd(longestTrack); - makeUdta(); + metadataHelper.makeUdta(); for (int i = 0; i < tracks.length; i++) { if (tracks[i].trak.tkhd.matrix.length != 36) { @@ -929,191 +946,6 @@ public class Mp4FromDashWriter { } - /** - * Create the 'udta' box with metadata fields. - * {@code udta} is a user data box that can contain various types of metadata, - * including title, artist, date, and cover art. - * @see Apple Quick Time Format Specification for user data atoms - * @see Multimedia Wiki FFmpeg Metadata - * @see atomicparsley docs - * for a short and understandable reference about metadata keys and values - * @throws IOException - */ - private void makeUdta() throws IOException { - if (streamInfo == null) { - return; - } - - // udta - final int startUdta = auxOffset(); - auxWrite(ByteBuffer.allocate(8).putInt(0).putInt(0x75647461).array()); // "udta" - - // meta (full box: type + version/flags) - final int startMeta = auxOffset(); - auxWrite(ByteBuffer.allocate(8).putInt(0).putInt(0x6D657461).array()); // "meta" - auxWrite(ByteBuffer.allocate(4).putInt(0).array()); // version & flags = 0 - - // hdlr inside meta - auxWrite(makeMetaHdlr()); - - // ilst container - final int startIlst = auxOffset(); - auxWrite(ByteBuffer.allocate(8).putInt(0).putInt(0x696C7374).array()); // "ilst" - - // write metadata items - - final var metaHelper = new StreamInfoMetadataHelper(streamInfo); - final String title = metaHelper.getTitle(); - final String artist = metaHelper.getArtist(); - final String date = metaHelper.getReleaseDate().getLocalDateTime() - .toLocalDate().toString(); - final String recordLabel = metaHelper.getRecordLabel(); - final String copyright = metaHelper.getCopyright(); - - if (title != null && !title.isEmpty()) { - writeMetaItem("©nam", title); - } - if (artist != null && !artist.isEmpty()) { - writeMetaItem("©ART", artist); - } - if (date != null && !date.isEmpty()) { - // this means 'year' in mp4 metadata, who the hell thought that? - writeMetaItem("©day", date); - } - if (recordLabel != null && !recordLabel.isEmpty()) { - writeMetaItem("©lab", recordLabel); - } - if (copyright != null && !copyright.isEmpty()) { - writeMetaItem("©cpy", copyright); - } - - if (thumbnail != null) { - final ByteArrayOutputStream baos = new ByteArrayOutputStream(); - thumbnail.compress(Bitmap.CompressFormat.PNG, 100, baos); - final byte[] imgBytes = baos.toByteArray(); - baos.close(); - // 0x0000000E = PNG type indicator for 'data' box (0x0D = JPEG) - writeMetaCover(imgBytes, 0x0000000E); - - } - - // fix lengths - lengthFor(startIlst); - lengthFor(startMeta); - lengthFor(startUdta); - - } - - /** - * Helper to write a metadata item inside the 'ilst' box. - * - *
-     *     [size][key] [data_box]
-     *     data_box = [size]["data"][type(4bytes)=1][locale(4bytes)=0][payload]
-     * 
- * - * @param keyStr 4-char metadata key - * @param value the metadata value - * @throws IOException - */ - // - private void writeMetaItem(final String keyStr, final String value) throws IOException { - final byte[] valBytes = value.getBytes(StandardCharsets.UTF_8); - final byte[] keyBytes = keyStr.getBytes(StandardCharsets.ISO_8859_1); - - final int dataBoxSize = 16 + valBytes.length; // 4(size)+4("data")+4(type/locale)+payload - final int itemBoxSize = 8 + dataBoxSize; // 4(size)+4(key)+dataBox - - final ByteBuffer buf = ByteBuffer.allocate(itemBoxSize); - buf.putInt(itemBoxSize); - // key (4 bytes) - if (keyBytes.length == 4) { - buf.put(keyBytes); - } else { - // fallback: pad or truncate - final byte[] kb = new byte[4]; - System.arraycopy(keyBytes, 0, kb, 0, Math.min(keyBytes.length, 4)); - buf.put(kb); - } - - // data box - buf.putInt(dataBoxSize); - buf.putInt(0x64617461); // "data" - buf.putInt(0x00000001); // well-known type indicator (UTF-8) - buf.putInt(0x00000000); // locale - buf.put(valBytes); - - auxWrite(buf.array()); - } - - /** - * Create a minimal hdlr box for the meta container. - * The boxsize is fixed (33 bytes) as no name is provided. - * @return byte array with the hdlr box - */ - private byte[] makeMetaHdlr() { - final ByteBuffer buf = ByteBuffer.allocate(33); - buf.putInt(33); - buf.putInt(0x68646C72); // "hdlr" - buf.putInt(0x00000000); // pre-defined - buf.putInt(0x6D646972); // "mdir" handler_type (metadata directory) - buf.putInt(0x00000000); // subtype / reserved - buf.put(new byte[12]); // reserved - buf.put((byte) 0x00); // name (empty, null-terminated) - return buf.array(); - } - - /** - * Helper to add cover image inside the 'udta' box. - *

- * This method writes the 'covr' metadata item which contains the cover image. - * The cover image is displayed as thumbnail in many media players and file managers. - *

- *
-     *     [size][key] [data_box]
-     *     data_box = [size]["data"][type(4bytes)][locale(4bytes)=0][payload]
-     * 
- * - * @param imageData image byte data - * @param dataType type indicator: 0x0000000E = PNG, 0x0000000D = JPEG - * @throws IOException - */ - private void writeMetaCover(final byte[] imageData, final int dataType) throws IOException { - if (imageData == null || imageData.length == 0) { - return; - } - - final byte[] keyBytes = "covr".getBytes(StandardCharsets.ISO_8859_1); - - // data box: 4(size) + 4("data") + 4(type) + 4(locale) + payload - final int dataBoxSize = 16 + imageData.length; - final int itemBoxSize = 8 + dataBoxSize; - - final ByteBuffer buf = ByteBuffer.allocate(itemBoxSize); - buf.putInt(itemBoxSize); - - // key (4 chars) - if (keyBytes.length == 4) { - buf.put(keyBytes); - } else { - final byte[] kb = new byte[4]; - System.arraycopy(keyBytes, 0, kb, 0, Math.min(keyBytes.length, 4)); - buf.put(kb); - } - - // data box - buf.putInt(dataBoxSize); - buf.putInt(0x64617461); // "data" - buf.putInt(dataType); // type indicator: 0x0000000E = PNG, 0x0000000D = JPEG - buf.putInt(0x00000000); // locale - buf.put(imageData); - - auxWrite(buf.array()); - } - - static class TablesInfo { int stts; int stsc; diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp4Metadata.java b/app/src/main/java/us/shandian/giga/postprocessing/Mp4Metadata.java new file mode 100644 index 000000000..f03c88b5a --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/Mp4Metadata.java @@ -0,0 +1,473 @@ +package us.shandian.giga.postprocessing; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Arrays; + +/** + * Postprocessing algorithm to insert metadata into an existing MP4 file + * by modifying/adding the 'udta' box inside 'moov'. + * + * @see + * https://atomicparsley.sourceforge.net/mpeg-4files.html for a quick summary on + * the MP4 file format and its specification. + * @see + * Apple Quick Time Format Specification which is the basis for MP4 file format + * and contains detailed information about the structure of MP4 files. + * @see Apple Quick Time Format Specification for user data atoms (udta) + */ +public class Mp4Metadata extends Postprocessing { + + Mp4Metadata() { + super(false, true, ALGORITHM_MP4_METADATA); + } + + @Override + boolean test(SharpStream... sources) throws IOException { + // quick check: ensure there's at least one source and it looks like an MP4, + // i.e. the file has a 'moov' box near the beginning. + // THe 'udta' box is inserted inside 'moov', so if there's no 'moov' we can't do anything. + if (sources == null || sources.length == 0 || sources[0] == null) return false; + + final SharpStream src = sources[0]; + try { + src.rewind(); + + // scan first few boxes until we find moov or reach a reasonable limit + final int MAX_SCAN = 8 * 1024 * 1024; // 8 MiB + int scanned = 0; + + while (scanned < MAX_SCAN) { + // read header + byte[] header = new byte[8]; + int r = readFully(src, header, 0, 8); + if (r < 8) break; + + final int boxSize = ByteBuffer.wrap(header, 0, 4).getInt(); + final int boxType = ByteBuffer.wrap(header, 4, 4).getInt(); + + if (boxType == 0x6D6F6F76) { // "moov" + return true; + } + + long skip = (boxSize > 8) ? (boxSize - 8) : 0; + // boxSize == 0 means extends to EOF -> stop scanning + if (boxSize == 0) break; + + // attempt skip + long skipped = src.skip(skip); + if (skipped < skip) break; + + scanned += 8 + (int) skip; + } + + return false; + } finally { + // best-effort rewind; ignore problems here + try { + src.rewind(); + } catch (IOException ignored) { + // nothing to do + } + } + } + + @Override + int process(SharpStream out, SharpStream... sources) throws IOException { + if (sources == null || sources.length == 0) return OK_RESULT; + + final SharpStream src = sources[0]; + src.rewind(); + + // helper buffer for copy + final byte[] buf = new byte[64 * 1024]; + + // copy until moov + while (true) { + // read header + byte[] header = new byte[8]; + int h = readFully(src, header, 0, 8); + if (h < 8) { + // no more data, nothing to do + return OK_RESULT; + } + + final int boxSize = ByteBuffer.wrap(header, 0, 4).getInt(); + final int boxType = ByteBuffer.wrap(header, 4, 4).getInt(); + + if (boxType != 0x6D6F6F76) { // not "moov" -> copy whole box + // write header + out.write(header); + + long remaining = (boxSize > 8) ? (boxSize - 8) : 0; + if (boxSize == 0) { + // box extends to EOF: copy rest and return + int r; + while ((r = src.read(buf)) > 0) { + out.write(buf, 0, r); + } + return OK_RESULT; + } + + while (remaining > 0) { + int read = src.read(buf, 0, (int) Math.min(buf.length, remaining)); + if (read <= 0) break; + out.write(buf, 0, read); + remaining -= read; + } + + continue; + } + + // found moov. read full moov box into memory + long moovSize = boxSize; + boolean hasLargeSize = false; + if (moovSize == 1) { + // extended size: read 8 bytes + byte[] ext = new byte[8]; + readFully(src, ext, 0, 8); + moovSize = ByteBuffer.wrap(ext).getLong(); + hasLargeSize = true; + } + + if (moovSize < 8) { + // malformed + return OK_RESULT; + } + + final int toRead = (int) (moovSize - (hasLargeSize ? 16 : 8)); + final byte[] moovPayload = new byte[toRead]; + readFully(src, moovPayload, 0, toRead); + + // search for udta inside moov + int udtaIndex = indexOfBox(moovPayload, 0x75647461); // "udta" + + if (udtaIndex < 0) { + // no udta: build udta using helper and insert before first 'trak' atom + byte[] udtaBytes = buildUdta(); + + int insertPos = indexOfBox(moovPayload, 0x7472616B); // "trak" + if (insertPos < 0) insertPos = moovPayload.length; + + byte[] newPayload = new byte[moovPayload.length + udtaBytes.length]; + System.arraycopy(moovPayload, 0, newPayload, 0, insertPos); + System.arraycopy(udtaBytes, 0, newPayload, insertPos, udtaBytes.length); + System.arraycopy(moovPayload, insertPos, newPayload, insertPos + udtaBytes.length, + moovPayload.length - insertPos); + + long newMoovSize = moovSize + udtaBytes.length; + long delta = newMoovSize - moovSize; + + // adjust chunk offsets in the new payload so stco/co64 entries point to correct mdat offsets + adjustChunkOffsetsRecursive(newPayload, 0, newPayload.length, delta); + + // write updated moov header + if (hasLargeSize) { + out.write(intToBytes(1)); + out.write(intToBytes(0x6D6F6F76)); // "moov" + out.write(longToBytes(newMoovSize)); + } else { + out.write(intToBytes((int) newMoovSize)); + out.write(intToBytes(0x6D6F6F76)); // "moov" + } + + out.write(newPayload); + + } else { + // udta exists: replace the existing udta box with newly built udta + // determine old udta size (support extended size and size==0 -> till end of moov) + if (udtaIndex + 8 > moovPayload.length) { + // malformed; just write original and continue + if (hasLargeSize) { + out.write(intToBytes(1)); + out.write(intToBytes(0x6D6F6F76)); // "moov" + out.write(longToBytes(moovSize)); + } else { + out.write(intToBytes((int) moovSize)); + out.write(intToBytes(0x6D6F6F76)); // "moov" + } + out.write(moovPayload); + } else { + int sizeField = readUInt32(moovPayload, udtaIndex); + long oldUdtaSize; + if (sizeField == 1) { + // extended + if (udtaIndex + 16 > moovPayload.length) { + oldUdtaSize = ((long) moovPayload.length) - udtaIndex; // fallback + } else { + oldUdtaSize = readUInt64(moovPayload, udtaIndex + 8); + } + } else if (sizeField == 0) { + // until end of file/moov + oldUdtaSize = ((long) moovPayload.length) - udtaIndex; + } else { + oldUdtaSize = sizeField & 0xFFFFFFFFL; + } + + // compute the integer length (bounded by remaining payload) + int oldUdtaIntLen = (int) Math.min(oldUdtaSize, (moovPayload.length - udtaIndex)); + + // build new udta + byte[] newUdta = buildUdta(); + + // If new udta fits into old udta area, overwrite in place and keep moov size unchanged + if (newUdta.length <= oldUdtaIntLen) { + byte[] newPayload = new byte[moovPayload.length]; + // copy prefix + System.arraycopy(moovPayload, 0, newPayload, 0, udtaIndex); + // copy new udta + System.arraycopy(newUdta, 0, newPayload, udtaIndex, newUdta.length); + // pad remaining old udta space with zeros + int padStart = udtaIndex + newUdta.length; + int padLen = oldUdtaIntLen - newUdta.length; + if (padLen > 0) { + Arrays.fill(newPayload, padStart, padStart + padLen, (byte) 0); + } + // copy suffix + int suffixStart = udtaIndex + oldUdtaIntLen; + System.arraycopy(moovPayload, suffixStart, newPayload, udtaIndex + oldUdtaIntLen, + moovPayload.length - suffixStart); + + // moovSize unchanged + if (hasLargeSize) { + out.write(intToBytes(1)); + out.write(intToBytes(0x6D6F6F76)); + out.write(longToBytes(moovSize)); + } else { + out.write(intToBytes((int) moovSize)); + out.write(intToBytes(0x6D6F6F76)); + } + out.write(newPayload); + + } else { + // construct new moov payload by replacing the old udta region (previous behavior) + int newPayloadLen = moovPayload.length - oldUdtaIntLen + newUdta.length; + byte[] newPayload = new byte[newPayloadLen]; + + // copy prefix + System.arraycopy(moovPayload, 0, newPayload, 0, udtaIndex); + // copy new udta + System.arraycopy(newUdta, 0, newPayload, udtaIndex, newUdta.length); + // copy suffix + int suffixStart = udtaIndex + oldUdtaIntLen; + System.arraycopy(moovPayload, suffixStart, newPayload, udtaIndex + newUdta.length, + moovPayload.length - suffixStart); + + long newMoovSize = moovSize - oldUdtaSize + newUdta.length; + long delta = newMoovSize - moovSize; + + // adjust chunk offsets in the new payload so stco/co64 entries point to correct mdat offsets + adjustChunkOffsetsRecursive(newPayload, 0, newPayload.length, delta); + + // write updated moov header + if (hasLargeSize) { + out.write(intToBytes(1)); + out.write(intToBytes(0x6D6F6F76)); // "moov" + out.write(longToBytes(newMoovSize)); + } else { + out.write(intToBytes((int) newMoovSize)); + out.write(intToBytes(0x6D6F6F76)); // "moov" + } + + out.write(newPayload); + } + } + } + + // copy rest of file + int r; + while ((r = src.read(buf)) > 0) { + out.write(buf, 0, r); + } + + return OK_RESULT; + } + } + + private void adjustChunkOffsetsRecursive(byte[] payload, int start, + int length, long delta) throws IOException { + int idx = start; + final int end = start + length; + while (idx + 8 <= end) { + int boxSize = readUInt32(payload, idx); + int boxType = readUInt32(payload, idx + 4); + + if (boxSize == 0) { + // box extends to end of parent + boxSize = end - idx; + } else if (boxSize < 0) { + break; + } + + int headerLen = 8; + long declaredSize = ((long) boxSize) & 0xFFFFFFFFL; + if (boxSize == 1) { + // extended size + if (idx + 16 > end) break; + declaredSize = readUInt64(payload, idx + 8); + headerLen = 16; + } + + int contentStart = idx + headerLen; + int contentLen = (int) (declaredSize - headerLen); + if (contentLen < 0 || contentStart + contentLen > end) { + // invalid, stop + break; + } + + if (boxType == 0x7374636F) { // 'stco' + // version/flags(4) entry_count(4) entries + int entryCountOff = contentStart + 4; + if (entryCountOff + 4 > end) return; + int count = readUInt32(payload, entryCountOff); + int entriesStart = entryCountOff + 4; + for (int i = 0; i < count; i++) { + int entryOff = entriesStart + i * 4; + if (entryOff + 4 > end) break; + long val = ((long) readUInt32(payload, entryOff)) & 0xFFFFFFFFL; + long newVal = val + delta; + if (newVal < 0 || newVal > 0xFFFFFFFFL) { + throw new IOException("stco entry overflow after applying delta"); + } + putUInt32(payload, entryOff, (int) newVal); + } + } else if (boxType == 0x636F3634) { // 'co64' + int entryCountOff = contentStart + 4; + if (entryCountOff + 4 > end) return; + int count = readUInt32(payload, entryCountOff); + int entriesStart = entryCountOff + 4; + for (int i = 0; i < count; i++) { + int entryOff = entriesStart + i * 8; + if (entryOff + 8 > end) break; + long val = readUInt64(payload, entryOff); + long newVal = val + delta; + putUInt64(payload, entryOff, newVal); + } + } else { + // recurse into container boxes + if (contentLen >= 8) { + adjustChunkOffsetsRecursive(payload, contentStart, contentLen, delta); + } + } + + idx += (int) declaredSize; + } + } + + private static int readUInt32(byte[] buf, int off) { + return ((buf[off] & 0xFF) << 24) | ((buf[off + 1] & 0xFF) << 16) + | ((buf[off + 2] & 0xFF) << 8) | (buf[off + 3] & 0xFF); + } + + private static long readUInt64(byte[] buf, int off) { + return ((long) readUInt32(buf, off) << 32) | ((long) readUInt32(buf, off + 4) & 0xFFFFFFFFL); + } + + private static void putUInt32(byte[] buf, int off, int v) { + buf[off] = (byte) ((v >>> 24) & 0xFF); + buf[off + 1] = (byte) ((v >>> 16) & 0xFF); + buf[off + 2] = (byte) ((v >>> 8) & 0xFF); + buf[off + 3] = (byte) (v & 0xFF); + } + + private static void putUInt64(byte[] buf, int off, long v) { + putUInt32(buf, off, (int) ((v >>> 32) & 0xFFFFFFFFL)); + putUInt32(buf, off + 4, (int) (v & 0xFFFFFFFFL)); + } + + private static int readFully(SharpStream in, byte[] buf, int off, int len) throws IOException { + int readTotal = 0; + while (readTotal < len) { + int r = in.read(buf, off + readTotal, len - readTotal); + if (r <= 0) break; + readTotal += r; + } + return readTotal; + } + + private static int indexOfBox(byte[] payload, int boxType) { + int idx = 0; + while (idx + 8 <= payload.length) { + int size = readUInt32(payload, idx); + int type = readUInt32(payload, idx + 4); + if (type == boxType) return idx; + if (size <= 0) break; + idx += size; + } + return -1; + } + + private static byte[] intToBytes(int v) { + return ByteBuffer.allocate(4).putInt(v).array(); + } + + private static byte[] longToBytes(long v) { + return ByteBuffer.allocate(8).putLong(v).array(); + } + + /** + * Build udta bytes using {@link Mp4MetadataHelper}. + */ + private byte[] buildUdta() throws IOException { + final GrowableByteArray aux = new GrowableByteArray(Math.max(64 * 1024, 256 * 1024)); + + final Mp4MetadataHelper helper = new Mp4MetadataHelper( + aux::position, + aux::put, + offset -> { + int size = aux.position() - offset; + aux.putInt(offset, size); + return size; + }, + streamInfo, + thumbnail + ); + + helper.makeUdta(); + + return aux.toByteArray(); + } + + /** + * Small growable byte array helper with minimal random-access putInt support + */ + private static final class GrowableByteArray { + private byte[] buf; + private int pos = 0; + + GrowableByteArray(int initial) { + buf = new byte[initial]; + } + + int position() { return pos; } + + void put(byte[] data) { + ensureCapacity(pos + data.length); + System.arraycopy(data, 0, buf, pos, data.length); + pos += data.length; + } + + void putInt(int offset, int value) { + ensureCapacity(offset + 4); + buf[offset] = (byte) ((value >>> 24) & 0xff); + buf[offset + 1] = (byte) ((value >>> 16) & 0xff); + buf[offset + 2] = (byte) ((value >>> 8) & 0xff); + buf[offset + 3] = (byte) (value & 0xff); + } + + private void ensureCapacity(int min) { + if (min <= buf.length) return; + int newCap = buf.length * 2; + while (newCap < min) newCap *= 2; + buf = Arrays.copyOf(buf, newCap); + } + + byte[] toByteArray() { + return Arrays.copyOf(buf, pos); + } + } + +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp4MetadataHelper.java b/app/src/main/java/us/shandian/giga/postprocessing/Mp4MetadataHelper.java new file mode 100644 index 000000000..d1927e431 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/Mp4MetadataHelper.java @@ -0,0 +1,224 @@ +package us.shandian.giga.postprocessing; + +import android.graphics.Bitmap; + +import androidx.annotation.Nullable; + +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.util.StreamInfoMetadataHelper; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import javax.annotation.Nonnull; + +public final class Mp4MetadataHelper { + + @Nullable + final StreamInfo streamInfo; + @Nullable final Bitmap thumbnail; + @Nonnull final Supplier auxOffset; + @Nonnull final Consumer auxWriteBytes; + @Nonnull final Function lengthFor; + public Mp4MetadataHelper(@Nonnull Supplier auxOffset, + @Nonnull Consumer auxWriteBytes, + @Nonnull Function lengthFor, + @Nullable final StreamInfo streamInfo, + @Nullable final Bitmap thumbnail) { + this.auxOffset = auxOffset; + this.auxWriteBytes = auxWriteBytes; + this.lengthFor = lengthFor; + this.streamInfo = streamInfo; + this.thumbnail = thumbnail; + } + + /** + * Create the 'udta' box with metadata fields. + * {@code udta} is a user data box that can contain various types of metadata, + * including title, artist, date, and cover art. + * @see Apple Quick Time Format Specification for user data atoms + * @see Multimedia Wiki FFmpeg Metadata + * @see atomicparsley docs + * for a short and understandable reference about metadata keys and values + * @throws IOException + */ + public void makeUdta() throws IOException { + if (streamInfo == null) { + return; + } + + // udta + final int startUdta = auxOffset.get(); + auxWriteBytes.accept(ByteBuffer.allocate(8).putInt(0).putInt(0x75647461).array()); // "udta" + + // meta (full box: type + version/flags) + final int startMeta = auxOffset.get(); + auxWriteBytes.accept(ByteBuffer.allocate(8).putInt(0).putInt(0x6D657461).array()); // "meta" + auxWriteBytes.accept(ByteBuffer.allocate(4).putInt(0).array()); // version & flags = 0 + + // hdlr inside meta + auxWriteBytes.accept(makeMetaHdlr()); + + // ilst container + final int startIlst = auxOffset.get(); + auxWriteBytes.accept(ByteBuffer.allocate(8).putInt(0).putInt(0x696C7374).array()); // "ilst" + + // write metadata items + + final var metaHelper = new StreamInfoMetadataHelper(streamInfo); + final String title = metaHelper.getTitle(); + final String artist = metaHelper.getArtist(); + final String date = metaHelper.getReleaseDate().getLocalDateTime() + .toLocalDate().toString(); + final String recordLabel = metaHelper.getRecordLabel(); + final String copyright = metaHelper.getCopyright(); + + if (title != null && !title.isEmpty()) { + writeMetaItem("©nam", title); + } + if (artist != null && !artist.isEmpty()) { + writeMetaItem("©ART", artist); + } + if (date != null && !date.isEmpty()) { + // this means 'year' in mp4 metadata, who the hell thought that? + writeMetaItem("©day", date); + } + if (recordLabel != null && !recordLabel.isEmpty()) { + writeMetaItem("©lab", recordLabel); + } + if (copyright != null && !copyright.isEmpty()) { + writeMetaItem("©cpy", copyright); + } + + if (thumbnail != null) { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + thumbnail.compress(Bitmap.CompressFormat.PNG, 100, baos); + final byte[] imgBytes = baos.toByteArray(); + baos.close(); + // 0x0000000E = PNG type indicator for 'data' box (0x0D = JPEG) + writeMetaCover(imgBytes, 0x0000000E); + + } + + // fix lengths + lengthFor.apply(startIlst); + lengthFor.apply(startMeta); + lengthFor.apply(startUdta); + + } + + /** + * Helper to write a metadata item inside the 'ilst' box. + * + *
+     *     [size][key] [data_box]
+     *     data_box = [size]["data"][type(4bytes)=1][locale(4bytes)=0][payload]
+     * 
+ * + * @param keyStr 4-char metadata key + * @param value the metadata value + * @throws IOException + */ + private void writeMetaItem(final String keyStr, final String value) throws IOException { + final byte[] valBytes = value.getBytes(StandardCharsets.UTF_8); + final byte[] keyBytes = keyStr.getBytes(StandardCharsets.ISO_8859_1); + + final int dataBoxSize = 16 + valBytes.length; // 4(size)+4("data")+4(type/locale)+payload + final int itemBoxSize = 8 + dataBoxSize; // 4(size)+4(key)+dataBox + + final ByteBuffer buf = ByteBuffer.allocate(itemBoxSize); + buf.putInt(itemBoxSize); + // key (4 bytes) + if (keyBytes.length == 4) { + buf.put(keyBytes); + } else { + // fallback: pad or truncate + final byte[] kb = new byte[4]; + System.arraycopy(keyBytes, 0, kb, 0, Math.min(keyBytes.length, 4)); + buf.put(kb); + } + + // data box + buf.putInt(dataBoxSize); + buf.putInt(0x64617461); // "data" + buf.putInt(0x00000001); // well-known type indicator (UTF-8) + buf.putInt(0x00000000); // locale + buf.put(valBytes); + + auxWriteBytes.accept(buf.array()); + } + + /** + * Create a minimal hdlr box for the meta container. + * The boxsize is fixed (33 bytes) as no name is provided. + * @return byte array with the hdlr box + */ + private byte[] makeMetaHdlr() { + final ByteBuffer buf = ByteBuffer.allocate(33); + buf.putInt(33); + buf.putInt(0x68646C72); // "hdlr" + buf.putInt(0x00000000); // pre-defined + buf.putInt(0x6D646972); // "mdir" handler_type (metadata directory) + buf.putInt(0x00000000); // subtype / reserved + buf.put(new byte[12]); // reserved + buf.put((byte) 0x00); // name (empty, null-terminated) + return buf.array(); + } + + /** + * Helper to add cover image inside the 'udta' box. + *

+ * This method writes the 'covr' metadata item which contains the cover image. + * The cover image is displayed as thumbnail in many media players and file managers. + *

+ *
+     *     [size][key] [data_box]
+     *     data_box = [size]["data"][type(4bytes)][locale(4bytes)=0][payload]
+     * 
+ * + * @param imageData image byte data + * @param dataType type indicator: 0x0000000E = PNG, 0x0000000D = JPEG + * @throws IOException + */ + private void writeMetaCover(final byte[] imageData, final int dataType) throws IOException { + if (imageData == null || imageData.length == 0) { + return; + } + + final byte[] keyBytes = "covr".getBytes(StandardCharsets.ISO_8859_1); + + // data box: 4(size) + 4("data") + 4(type) + 4(locale) + payload + final int dataBoxSize = 16 + imageData.length; + final int itemBoxSize = 8 + dataBoxSize; + + final ByteBuffer buf = ByteBuffer.allocate(itemBoxSize); + buf.putInt(itemBoxSize); + + // key (4 chars) + if (keyBytes.length == 4) { + buf.put(keyBytes); + } else { + final byte[] kb = new byte[4]; + System.arraycopy(keyBytes, 0, kb, 0, Math.min(keyBytes.length, 4)); + buf.put(kb); + } + + // data box + buf.putInt(dataBoxSize); + buf.putInt(0x64617461); // "data" + buf.putInt(dataType); // type indicator: 0x0000000E = PNG, 0x0000000D = JPEG + buf.putInt(0x00000000); // locale + buf.put(imageData); + + auxWriteBytes.accept(buf.array()); + } + + +} 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 23976e7d6..1398e0d01 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java @@ -29,6 +29,7 @@ public abstract class Postprocessing implements Serializable { public static final String ALGORITHM_TTML_CONVERTER = "ttml"; public static final String ALGORITHM_WEBM_MUXER = "webm"; + public static final String ALGORITHM_MP4_METADATA = "mp4-metadata"; public static final String ALGORITHM_MP4_FROM_DASH_MUXER = "mp4D-mp4"; public static final String ALGORITHM_M4A_NO_DASH = "mp4D-m4a"; public static final String ALGORITHM_OGG_FROM_WEBM_DEMUXER = "webm-ogg-d"; @@ -44,6 +45,9 @@ public abstract class Postprocessing implements Serializable { case ALGORITHM_WEBM_MUXER: instance = new WebMMuxer(); break; + case ALGORITHM_MP4_METADATA: + instance = new Mp4Metadata(); + break; case ALGORITHM_MP4_FROM_DASH_MUXER: instance = new Mp4FromDashMuxer(); break; From 9951bb9fcb23b4abd466e610796a04889baa5de0 Mon Sep 17 00:00:00 2001 From: tobigr Date: Sun, 8 Feb 2026 14:33:18 +0100 Subject: [PATCH 14/17] Crop YouTube Music album covers --- .../us/shandian/giga/get/DownloadMission.java | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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 e3e0c6cab..0c97433fd 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -41,6 +41,7 @@ import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; +import static org.schabi.newpipe.extractor.ServiceList.YouTube; public class DownloadMission extends Mission { private static final long serialVersionUID = 6L;// last bump: 07 october 2019 @@ -866,12 +867,25 @@ public class DownloadMission extends Mission { final String thumbnailUrl = ImageStrategy.choosePreferredImage( images, PreferredImageQuality.HIGH); // TODO: get context from somewhere else - thumbnail = CoilHelper.INSTANCE.loadBitmapBlocking(App.getInstance(), thumbnailUrl); + Bitmap originalThumbnail = CoilHelper.INSTANCE.loadBitmapBlocking( + App.getInstance(), thumbnailUrl); + + // YouTube Music streams have non square thumbnails to fit the player aspect ratio + // of 16:9. We can safely crop the thumbnail to a square because the squared thumbnail + // is padded with bars on the sides. + if (originalThumbnail != null && streamInfo.getService().equals(YouTube) + && streamInfo.getSongMetadata() != null // i.e. YT Music stream + && originalThumbnail.getWidth() > originalThumbnail.getHeight()) { + int cropSize = Math.min(originalThumbnail.getWidth(), originalThumbnail.getHeight()); + int xOffset = (originalThumbnail.getWidth() - cropSize) / 2; + originalThumbnail = Bitmap.createBitmap(originalThumbnail, xOffset, 0, + cropSize, cropSize); + } + this.thumbnail = originalThumbnail; thumbnailFetched = true; } catch (final Exception e) { Log.w(TAG, "fetchThumbnail: failed to load thumbnail", e); thumbnailFetched = true; - return; } } From ae72d2f63d37888d138c8dbb4f2bf3fd43bb17df Mon Sep 17 00:00:00 2001 From: tobigr Date: Mon, 9 Feb 2026 17:18:52 +0100 Subject: [PATCH 15/17] Make Bitmap transient Serialization is attempted if something crashes. Bitmap cannot be serialized. --- app/src/main/java/us/shandian/giga/get/DownloadMission.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0c97433fd..01401c0db 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -70,7 +70,7 @@ public class DownloadMission extends Mission { static final int ERROR_HTTP_FORBIDDEN = 403; private StreamInfo streamInfo; - protected volatile Bitmap thumbnail; + protected transient volatile Bitmap thumbnail; protected volatile boolean thumbnailFetched = false; /** From 41c6bb0face868d75c9924dee4162110362183c1 Mon Sep 17 00:00:00 2001 From: tobigr Date: Mon, 9 Feb 2026 22:14:44 +0100 Subject: [PATCH 16/17] Add metadata and cover art to mp3 files --- .../newpipe/download/DownloadDialog.java | 12 +- .../giga/postprocessing/Mp3Metadata.java | 321 ++++++++++++++++++ .../giga/postprocessing/Postprocessing.java | 4 + 3 files changed, 332 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/Mp3Metadata.java diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java index e5153856e..57b25005e 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -1062,11 +1062,13 @@ public class DownloadDialog extends DialogFragment kind = 'a'; selectedStream = audioStreamsAdapter.getItem(selectedAudioIndex); - if (selectedStream.getFormat() == MediaFormat.M4A) { - psName = Postprocessing.ALGORITHM_M4A_NO_DASH; - } else if (selectedStream.getFormat() == MediaFormat.WEBMA_OPUS) { - psName = Postprocessing.ALGORITHM_OGG_FROM_WEBM_DEMUXER; - } + psName = switch (selectedStream.getFormat()) { + case M4A -> Postprocessing.ALGORITHM_M4A_NO_DASH; + case WEBMA_OPUS -> Postprocessing.ALGORITHM_OGG_FROM_WEBM_DEMUXER; + case MP3 -> Postprocessing.ALGORITHM_MP3_METADATA; + default -> null; + }; + break; case R.id.video_button: kind = 'v'; diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp3Metadata.java b/app/src/main/java/us/shandian/giga/postprocessing/Mp3Metadata.java new file mode 100644 index 000000000..a839de582 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/Mp3Metadata.java @@ -0,0 +1,321 @@ +package us.shandian.giga.postprocessing; + +import static java.time.ZoneOffset.UTC; + +import android.graphics.Bitmap; + +import org.schabi.newpipe.streams.io.SharpInputStream; +import org.schabi.newpipe.streams.io.SharpStream; +import org.schabi.newpipe.util.StreamInfoMetadataHelper; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.PushbackInputStream; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import javax.annotation.Nonnull; + +/** + * Adds Metadata tp to an MP3 file by writing ID3v2.4 frames, i.e. metadata tags, + * at the start of the file. + * @see ID3v2.4 specification + * @see ID3v2.4 frames + */ +public class Mp3Metadata extends Postprocessing { + + Mp3Metadata() { + super(true, true, ALGORITHM_MP3_METADATA); + } + + @Override + int process(SharpStream out, SharpStream... sources) throws IOException { + if (sources == null || sources.length == 0 || sources[0] == null) { + // nothing to do + return OK_RESULT; + } + + // MP3 metadata is stored in ID3v2 tags at the start of the file, + // so we need to build the tag in memory first and then write it + // before copying the rest of the file. + + final ByteArrayOutputStream frames = new ByteArrayOutputStream(); + final FrameWriter fw = new FrameWriter(frames); + + makeMetadata(fw); + makePictureFrame(fw); + + byte[] framesBytes = frames.toByteArray(); + + + // ID3 header: 'ID3' + ver(0x04,0x00) + flags(0) + size (synchsafe 4 bytes) + final ByteArrayOutputStream tag = new ByteArrayOutputStream(); + tag.write(new byte[]{'I', 'D', '3'}); + tag.write(0x04); // version 2.4 + tag.write(0x00); // revision + tag.write(0x00); // flags + int tagSize = framesBytes.length; // size excluding 10-byte header + tag.write(toSynchsafe(tagSize)); + tag.write(framesBytes); + + + byte[] tagBytes = tag.toByteArray(); + out.write(tagBytes); + try (InputStream sIn = new SharpInputStream(sources[0])) { + copyStreamSkippingId3(sIn, out); + } + out.flush(); + + return OK_RESULT; + + } + + /** + * Write metadata frames based on the StreamInfo's metadata. + * @see ID3v2.4 frames for a list of frame types + * and their identifiers. + * @param fw the FrameWriter to write frames to + * @throws IOException if an I/O error occurs while writing frames + */ + private void makeMetadata(FrameWriter fw) throws IOException { + var metadata = new StreamInfoMetadataHelper(this.streamInfo); + + fw.writeTextFrame("TIT2", metadata.getTitle()); + fw.writeTextFrame("TPE1", metadata.getArtist()); + fw.writeTextFrame("TCOM", metadata.getComposer()); + fw.writeTextFrame("TIPL", metadata.getPerformer()); + fw.writeTextFrame("TCON", metadata.getGenre()); + fw.writeTextFrame("TALB", metadata.getAlbum()); + + final LocalDateTime releaseDate = metadata.getReleaseDate().getLocalDateTime(UTC); + // determine precision by checking that lower-order fields are at their "zero"/start values + boolean isOnlyMonth = releaseDate.getDayOfMonth() == 1 + && releaseDate.getHour() == 0 + && releaseDate.getMinute() == 0 + && releaseDate.getSecond() == 0 + && releaseDate.getNano() == 0; + boolean isOnlyYear = releaseDate.getMonthValue() == 1 + && isOnlyMonth; + // see https://id3.org/id3v2.4.0-structure > 4. ID3v2 frame overview + // for date formats in TDRC frame + final String datePattern; + if (isOnlyYear) { + datePattern = "yyyy"; + } else if (isOnlyMonth) { + datePattern = "yyyy-MM"; + } else { + datePattern = "yyyy-MM-dd"; + } + fw.writeTextFrame("TDRC", + releaseDate.format(DateTimeFormatter.ofPattern(datePattern))); + + + if (metadata.getTrackNumber() != null) { + fw.writeTextFrame("TRCK", String.valueOf(metadata.getTrackNumber())); + } + + fw.writeTextFrame("TPUB", metadata.getRecordLabel()); + fw.writeTextFrame("TCOP", metadata.getCopyright()); + + // WXXX is a user defined URL link frame, we can use it to store the URL of the stream + // However, since it's user defined, so not all players support it. + // Using the comment frame (COMM) as fallback + fw.writeTextFrame("WXXX", streamInfo.getUrl()); + fw.writeCommentFrame("eng", streamInfo.getUrl()); + } + + /** + * Write a picture frame (APIC) with the thumbnail image if available. + * @param fw the FrameWriter to write the picture frame to + * @throws IOException if an I/O error occurs while writing the frame + */ + private void makePictureFrame(FrameWriter fw) throws IOException { + if (thumbnail != null) { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + thumbnail.compress(Bitmap.CompressFormat.PNG, 100, baos); + final byte[] imgBytes = baos.toByteArray(); + baos.close(); + fw.writePictureFrame("image/png", imgBytes); + } + } + + /** + * Copy the input stream to the output stream, but if the input stream starts with an ID3v2 tag, + * skip the tag and only copy the audio data. + * @param in the input stream to read from (should be at the start of the MP3 file) + * @param out the output stream to write to + * @throws IOException if an I/O error occurs while reading or writing + */ + private static void copyStreamSkippingId3(InputStream in, SharpStream out) throws IOException { + PushbackInputStream pin = (in instanceof PushbackInputStream) ? (PushbackInputStream) in : new PushbackInputStream(in, 10); + byte[] header = new byte[10]; + int hr = pin.read(header); + if (hr == 10 && header[0] == 'I' && header[1] == 'D' && header[2] == '3') { + // bytes 3 and 4 are version and revision and byte 5 is flags + // the size is stored as synchsafe at bytes 6..9 + int size = fromSynchsafe(header, 6); + long remaining = size; + // consume exactly 'size' bytes, i.e. the rest of the metadata frames, from the stream + byte[] skipBuf = new byte[8192]; + while (remaining > 0) { + int toRead = (int) Math.min(skipBuf.length, remaining); + int r = pin.read(skipBuf, 0, toRead); + if (r <= 0) break; + remaining -= r; + } + } else { + // push header bytes back so copy will include them + if (hr > 0) pin.unread(header, 0, hr); + } + + // copy rest + byte[] buf = new byte[8192]; + int r; + while ((r = pin.read(buf)) > 0) out.write(buf, 0, r); + } + + /** + * Create a 4-byte synchsafe integer from a regular integer value. + * @see ID3v2.4 specification section + * 6.2. Synchsafe integers + * @param value the integer value to convert (should be non-negative and less than 2^28) + * @return the synchsafe byte array + */ + private static byte[] toSynchsafe(int value) { + byte[] b = new byte[4]; + b[0] = (byte) ((value >> 21) & 0x7F); + b[1] = (byte) ((value >> 14) & 0x7F); + b[2] = (byte) ((value >> 7) & 0x7F); + b[3] = (byte) (value & 0x7F); + return b; + } + + /** + * Get a regular integer from a 4-byte synchsafe byte array. + * @see ID3v2.4 specification section + * 6.2. Synchsafe integers + * @param b the byte array containing the synchsafe integer + * (should be at least 4 bytes + offset long) + * @param offset the offset in the byte array where the synchsafe integer starts + * @return the regular integer value + */ + private static int fromSynchsafe(byte[] b, int offset) { + return ((b[offset] & 0x7F) << 21) + | ((b[offset + 1] & 0x7F) << 14) + | ((b[offset + 2] & 0x7F) << 7) + | (b[offset + 3] & 0x7F); + } + + + /** + * Helper class to write ID3v2.4 frames to a ByteArrayOutputStream. + */ + private static class FrameWriter { + + /** + * This separator is used to separate multiple entries in a list of an ID3v2 text frame. + * @see ID3v2.4 frames section + * 4.2. Text information frames + */ + private static final Character TEXT_LIST_SEPARATOR = 0x00; + private static final byte UTF8_ENCODING_BYTE = 0x03; + + private final ByteArrayOutputStream out; + + FrameWriter(ByteArrayOutputStream out) { + this.out = out; + } + + /** + * Write a text frame with the given identifier and text content. + * @param id the 4 character long frame identifier + * @param text the text content to write. If null or blank, no frame is written. + * @throws IOException if an I/O error occurs while writing the frame + */ + void writeTextFrame(String id, String text) throws IOException { + if (text == null || text.isBlank()) return; + byte[] data = text.getBytes(StandardCharsets.UTF_8); + ByteArrayOutputStream frame = new ByteArrayOutputStream(); + frame.write(UTF8_ENCODING_BYTE); + frame.write(data); + writeFrame(id, frame.toByteArray()); + } + + /** + * Write a text frame that can contain multiple entries separated by the + * {@link #TEXT_LIST_SEPARATOR}. + * @param id the 4 character long frame identifier + * @param texts the list of text entries to write. If null or empty, no frame is written. + * Blank or null entries are skipped. + * @throws IOException if an I/O error occurs while writing the frame + */ + void writeTextFrame(String id, List texts) throws IOException { + if (texts == null || texts.isEmpty()) return; + ByteArrayOutputStream frame = new ByteArrayOutputStream(); + frame.write(UTF8_ENCODING_BYTE); + for (int i = 0; i < texts.size(); i++) { + String text = texts.get(i); + if (text != null && !text.isBlank()) { + byte[] data = text.getBytes(StandardCharsets.UTF_8); + frame.write(data); + if (i < texts.size() - 1) { + frame.write(TEXT_LIST_SEPARATOR); + } + } + } + writeFrame(id, frame.toByteArray()); + } + + /** + * Write a picture frame (APIC) with the given MIME type and image data. + * @see ID3v2.4 frames section + * 4.14. Attached picture + * @param mimeType the MIME type of the image (e.g. "image/png" or "image/jpeg"). + * @param imageData the binary data of the image. If empty, no frame is written. + * @throws IOException + */ + void writePictureFrame(@Nonnull String mimeType, @Nonnull byte[] imageData) + throws IOException { + if (imageData.length == 0) return; + ByteArrayOutputStream frame = new ByteArrayOutputStream(); + frame.write(UTF8_ENCODING_BYTE); + frame.write(mimeType.getBytes(StandardCharsets.US_ASCII)); + frame.write(0x00); + frame.write(0x03); // picture type: 3 = cover(front) + frame.write(0x00); // empty description terminator (UTF-8 empty string) + // Then the picture bytes + frame.write(imageData); + writeFrame("APIC", frame.toByteArray()); + } + + /** + * Write a comment frame (COMM) with the given language and comment text. + * @param lang a 3-character ISO-639-2 language code (e.g. "eng" for English). + * If null or invalid, defaults to "eng". + * @param comment the comment text to write. If null, no frame is written. + * @throws IOException + */ + void writeCommentFrame(String lang, String comment) throws IOException { + if (comment == null) return; + if (lang == null || lang.length() != 3) lang = "eng"; + ByteArrayOutputStream frame = new ByteArrayOutputStream(); + frame.write(UTF8_ENCODING_BYTE); + frame.write(lang.getBytes(StandardCharsets.US_ASCII)); + frame.write(0x00); // short content descriptor (empty) terminator + frame.write(comment.getBytes(StandardCharsets.UTF_8)); + writeFrame("COMM", frame.toByteArray()); + } + + private void writeFrame(String id, byte[] data) throws IOException { + if (data == null || data.length == 0) return; + // frame header: id(4) size(4 synchsafe) flags(2) + out.write(id.getBytes(StandardCharsets.US_ASCII)); + out.write(toSynchsafe(data.length)); + out.write(new byte[]{0x00, 0x00}); + out.write(data); + } + } +} 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 1398e0d01..3cb8b530c 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java @@ -29,6 +29,7 @@ public abstract class Postprocessing implements Serializable { public static final String ALGORITHM_TTML_CONVERTER = "ttml"; public static final String ALGORITHM_WEBM_MUXER = "webm"; + public static final String ALGORITHM_MP3_METADATA = "mp3-metadata"; public static final String ALGORITHM_MP4_METADATA = "mp4-metadata"; public static final String ALGORITHM_MP4_FROM_DASH_MUXER = "mp4D-mp4"; public static final String ALGORITHM_M4A_NO_DASH = "mp4D-m4a"; @@ -45,6 +46,9 @@ public abstract class Postprocessing implements Serializable { case ALGORITHM_WEBM_MUXER: instance = new WebMMuxer(); break; + case ALGORITHM_MP3_METADATA: + instance = new Mp3Metadata(); + break; case ALGORITHM_MP4_METADATA: instance = new Mp4Metadata(); break; From ee5f52ebc22da8a707895fc1eab76b62268607fd Mon Sep 17 00:00:00 2001 From: tobigr Date: Mon, 9 Feb 2026 22:59:36 +0100 Subject: [PATCH 17/17] Add option to DownloadDialog to enable metadata embedding Metadata embedding is disabled by default across all post-processing algorithms. A new parameter and variable is introduced although there is the parameter 'args' already. The information on whether the metadata is going to be embedded needs to be parsed by every audio or video post-processing algorithm anyway. Getting it from the list is more difficult and less error-prone than creating a new param. --- .../newpipe/download/DownloadDialog.java | 5 ++-- .../newpipe/streams/Mp4FromDashWriter.java | 9 ++++-- .../newpipe/streams/OggFromWebMWriter.java | 12 ++++++-- .../giga/postprocessing/M4aNoDash.java | 2 +- .../giga/postprocessing/Mp3Metadata.java | 5 ++++ .../giga/postprocessing/Mp4FromDashMuxer.java | 3 +- .../giga/postprocessing/Mp4Metadata.java | 3 ++ .../postprocessing/OggFromWebmDemuxer.java | 2 +- .../giga/postprocessing/Postprocessing.java | 10 ++++++- .../giga/service/DownloadManagerService.java | 29 ++++++++++--------- app/src/main/res/layout/download_dialog.xml | 12 +++++++- app/src/main/res/values/strings.xml | 1 + 12 files changed, 68 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java index 57b25005e..a770e47c6 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -1049,6 +1049,7 @@ public class DownloadDialog extends DialogFragment final Stream selectedStream; Stream secondaryStream = null; final char kind; + final boolean embedMetadata = dialogBinding.metadataSwitch.isChecked(); int threads = dialogBinding.threads.getProgress() + 1; final String[] urls; final List recoveryInfo; @@ -1136,8 +1137,8 @@ public class DownloadDialog extends DialogFragment ); } - DownloadManagerService.startMission(context, urls, storage, kind, threads, - currentInfo, psName, psArgs, nearLength, new ArrayList<>(recoveryInfo)); + DownloadManagerService.startMission(context, urls, storage, kind, threads, currentInfo, + psName, embedMetadata, psArgs, nearLength, new ArrayList<>(recoveryInfo)); Toast.makeText(context, getString(R.string.download_has_started), Toast.LENGTH_SHORT).show(); diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java index 73bd51e3d..7094d672d 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java @@ -64,9 +64,11 @@ public class Mp4FromDashWriter { private final ArrayList compatibleBrands = new ArrayList<>(5); + private final boolean embedMetadata; private final Mp4MetadataHelper metadataHelper; - public Mp4FromDashWriter(final StreamInfo streamInfo, + public Mp4FromDashWriter(final boolean embedMetadata, + final StreamInfo streamInfo, final Bitmap thumbnail, final SharpStream... sources) throws IOException { for (final SharpStream src : sources) { @@ -75,6 +77,7 @@ public class Mp4FromDashWriter { } } + this.embedMetadata = embedMetadata; this.metadataHelper = new Mp4MetadataHelper( this::auxOffset, buffer -> { @@ -750,7 +753,9 @@ public class Mp4FromDashWriter { makeMvhd(longestTrack); - metadataHelper.makeUdta(); + if (embedMetadata) { + metadataHelper.makeUdta(); + } for (int i = 0; i < tracks.length; i++) { if (tracks[i].trak.tkhd.matrix.length != 36) { 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 c043fff87..e39a0ada7 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java @@ -121,6 +121,7 @@ public class OggFromWebMWriter implements Closeable { private long segmentTableNextTimestamp = TIME_SCALE_NS; private final int[] crc32Table = new int[256]; + private final boolean embedMetadata; private final StreamInfo streamInfo; private final Bitmap thumbnail; @@ -128,11 +129,13 @@ public class OggFromWebMWriter implements Closeable { * Constructor of OggFromWebMWriter. * @param source * @param target + * @param embedMetadata whether to embed metadata in the output Ogg stream * @param streamInfo the stream info * @param thumbnail the thumbnail bitmap used as cover art */ public OggFromWebMWriter(@NonNull final SharpStream source, @NonNull final SharpStream target, + final boolean embedMetadata, @Nullable final StreamInfo streamInfo, @Nullable final Bitmap thumbnail) { if (!source.canRead() || !source.canRewind()) { @@ -144,6 +147,7 @@ public class OggFromWebMWriter implements Closeable { this.source = source; this.output = target; + this.embedMetadata = embedMetadata; this.streamInfo = streamInfo; this.thumbnail = thumbnail; @@ -264,9 +268,11 @@ public class OggFromWebMWriter implements Closeable { } /* step 3: create packet with metadata */ - final byte[] buffer = makeCommentHeader(); - if (buffer != null) { - addPacketSegmentMultiPage(buffer, header); + if (embedMetadata) { + final byte[] buffer = makeCommentHeader(); + if (buffer != null) { + addPacketSegmentMultiPage(buffer, header); + } } /* step 4: calculate amount of packets */ diff --git a/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java b/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java index ec57ed491..d05d5c235 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java @@ -31,7 +31,7 @@ class M4aNoDash extends Postprocessing { @Override int process(SharpStream out, SharpStream... sources) throws IOException { Mp4FromDashWriter muxer = new Mp4FromDashWriter( - this.streamInfo, this.thumbnail, sources[0]); + this.embedMetadata, this.streamInfo, this.thumbnail, sources[0]); muxer.setMainBrand(0x4D344120);// binary string "M4A " muxer.parseSources(); muxer.selectTracks(0); diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp3Metadata.java b/app/src/main/java/us/shandian/giga/postprocessing/Mp3Metadata.java index a839de582..b585cc139 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/Mp3Metadata.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/Mp3Metadata.java @@ -31,6 +31,11 @@ public class Mp3Metadata extends Postprocessing { super(true, true, ALGORITHM_MP3_METADATA); } + @Override + boolean test(SharpStream... sources) { + return this.embedMetadata; + } + @Override int process(SharpStream out, SharpStream... sources) throws IOException { if (sources == null || sources.length == 0 || sources[0] == null) { diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java index 887ba1bf5..43333f1cb 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java @@ -16,7 +16,8 @@ class Mp4FromDashMuxer extends Postprocessing { @Override int process(SharpStream out, SharpStream... sources) throws IOException { - Mp4FromDashWriter muxer = new Mp4FromDashWriter(this.streamInfo, this.thumbnail, sources); + Mp4FromDashWriter muxer = new Mp4FromDashWriter( + this.embedMetadata, this.streamInfo, this.thumbnail, sources); muxer.parseSources(); muxer.selectTracks(0, 0); muxer.build(out); diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp4Metadata.java b/app/src/main/java/us/shandian/giga/postprocessing/Mp4Metadata.java index f03c88b5a..40fa15f29 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/Mp4Metadata.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/Mp4Metadata.java @@ -27,6 +27,9 @@ public class Mp4Metadata extends Postprocessing { @Override boolean test(SharpStream... sources) throws IOException { + // nothing to do if metadata should not be embedded + if (!embedMetadata) return false; + // quick check: ensure there's at least one source and it looks like an MP4, // i.e. the file has a 'moov' box near the beginning. // THe 'udta' box is inserted inside 'moov', so if there's no 'moov' we can't do anything. 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 f76fadb31..d4d0c4637 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java @@ -35,7 +35,7 @@ class OggFromWebmDemuxer extends Postprocessing { @Override int process(SharpStream out, @NonNull SharpStream... sources) throws IOException { OggFromWebMWriter demuxer = new OggFromWebMWriter( - sources[0], out, streamInfo, thumbnail); + sources[0], out, embedMetadata, 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 3cb8b530c..ae1068c3a 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java @@ -35,7 +35,9 @@ public abstract class Postprocessing implements Serializable { public static final String ALGORITHM_M4A_NO_DASH = "mp4D-m4a"; public static final String ALGORITHM_OGG_FROM_WEBM_DEMUXER = "webm-ogg-d"; - public static Postprocessing getAlgorithm(@NonNull String algorithmName, String[] args, + public static Postprocessing getAlgorithm(@NonNull String algorithmName, + boolean embedMetadata, + String[] args, @NonNull StreamInfo streamInfo) { Postprocessing instance; @@ -67,6 +69,7 @@ public abstract class Postprocessing implements Serializable { instance.args = args; instance.streamInfo = streamInfo; + instance.embedMetadata = embedMetadata; return instance; } @@ -88,6 +91,11 @@ public abstract class Postprocessing implements Serializable { private String[] args; + /** + * Indicates whether the metadata should be embedded in the file or not. + */ + boolean embedMetadata; + /** * StreamInfo object related to the current download */ 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 6ce6a4b38..3cf55e156 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -40,7 +40,6 @@ 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; @@ -75,6 +74,7 @@ public class DownloadManagerService extends Service { private static final String EXTRA_KIND = "DownloadManagerService.extra.kind"; private static final String EXTRA_THREADS = "DownloadManagerService.extra.threads"; private static final String EXTRA_POSTPROCESSING_NAME = "DownloadManagerService.extra.postprocessingName"; + private static final String EXTRA_POSTPROCESSING_METADATA = "DownloadManagerService.extra.postprocessingMetadata"; private static final String EXTRA_POSTPROCESSING_ARGS = "DownloadManagerService.extra.postprocessingArgs"; private static final String EXTRA_NEAR_LENGTH = "DownloadManagerService.extra.nearLength"; private static final String EXTRA_PATH = "DownloadManagerService.extra.storagePath"; @@ -349,20 +349,21 @@ public class DownloadManagerService extends Service { /** * Start a new download mission * - * @param context the activity context - * @param urls array of urls to download - * @param storage where the file is saved - * @param kind type of file (a: audio v: video s: subtitle ?: file-extension defined) - * @param threads the number of threads maximal used to download chunks of the file. - * @param psName the name of the required post-processing algorithm, or {@code null} to ignore. - * @param streamInfo stream metadata that may be written into the downloaded file. - * @param psArgs the arguments for the post-processing algorithm. - * @param nearLength the approximated final length of the file - * @param recoveryInfo array of MissionRecoveryInfo, in case is required recover the download + * @param context the activity context + * @param urls array of urls to download + * @param storage where the file is saved + * @param kind type of file (a: audio v: video s: subtitle ?: file-extension defined) + * @param threads the number of threads maximal used to download chunks of the file. + * @param streamInfo stream metadata that may be written into the downloaded file. + * @param psName the name of the required post-processing algorithm, or {@code null} to ignore. + * @param embedMetadata whether the metadata should be embedded into the downloaded file. + * @param psArgs the arguments for the post-processing algorithm. + * @param nearLength the approximated final length of the file + * @param recoveryInfo array of MissionRecoveryInfo, in case is required recover the download */ public static void startMission(Context context, String[] urls, StoredFileHelper storage, char kind, int threads, StreamInfo streamInfo, String psName, - String[] psArgs, long nearLength, + boolean embedMetadata, String[] psArgs, long nearLength, ArrayList recoveryInfo) { final Intent intent = new Intent(context, DownloadManagerService.class) .setAction(Intent.ACTION_RUN) @@ -370,6 +371,7 @@ public class DownloadManagerService extends Service { .putExtra(EXTRA_KIND, kind) .putExtra(EXTRA_THREADS, threads) .putExtra(EXTRA_POSTPROCESSING_NAME, psName) + .putExtra(EXTRA_POSTPROCESSING_METADATA, embedMetadata) .putExtra(EXTRA_POSTPROCESSING_ARGS, psArgs) .putExtra(EXTRA_NEAR_LENGTH, nearLength) .putExtra(EXTRA_RECOVERY_INFO, recoveryInfo) @@ -388,6 +390,7 @@ public class DownloadManagerService extends Service { int threads = intent.getIntExtra(EXTRA_THREADS, 1); char kind = intent.getCharExtra(EXTRA_KIND, '?'); String psName = intent.getStringExtra(EXTRA_POSTPROCESSING_NAME); + boolean embedMetadata = intent.getBooleanExtra(EXTRA_POSTPROCESSING_METADATA, false); String[] psArgs = intent.getStringArrayExtra(EXTRA_POSTPROCESSING_ARGS); long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0); String tag = intent.getStringExtra(EXTRA_STORAGE_TAG); @@ -407,7 +410,7 @@ public class DownloadManagerService extends Service { if (psName == null) ps = null; else - ps = Postprocessing.getAlgorithm(psName, psArgs, streamInfo); + ps = Postprocessing.getAlgorithm(psName, embedMetadata, psArgs, streamInfo); final DownloadMission mission = new DownloadMission( urls, storage, kind, ps, streamInfo, getApplicationContext()); diff --git a/app/src/main/res/layout/download_dialog.xml b/app/src/main/res/layout/download_dialog.xml index 67aa1577c..5541d6545 100644 --- a/app/src/main/res/layout/download_dialog.xml +++ b/app/src/main/res/layout/download_dialog.xml @@ -105,11 +105,21 @@ android:text="@string/audio_track_present_in_video" android:textSize="12sp" /> + + No download folder set yet, choose the default download folder now This permission is needed to\nopen in popup mode 1 item deleted. + Embed metadata such as title, author, thumbnail reCAPTCHA challenge Press \"Done\" when solved