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 0857fa339..741bda246 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -1133,7 +1133,7 @@ public class DownloadDialog extends DialogFragment } DownloadManagerService.startMission(context, urls, storage, kind, threads, - currentInfo.getUrl(), psName, psArgs, nearLength, new ArrayList<>(recoveryInfo)); + currentInfo, psName, 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/OggFromWebMWriter.java b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java index e32ccd214..b3d095e8e 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java @@ -1,8 +1,12 @@ package org.schabi.newpipe.streams; +import android.util.Log; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.schabi.newpipe.BuildConfig; +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; @@ -14,6 +18,8 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.stream.Collectors; /** * @author kapodamy @@ -53,8 +59,10 @@ public class OggFromWebMWriter implements Closeable { private long segmentTableNextTimestamp = TIME_SCALE_NS; private final int[] crc32Table = new int[256]; + private final StreamInfo streamInfo; - public OggFromWebMWriter(@NonNull final SharpStream source, @NonNull final SharpStream target) { + public OggFromWebMWriter(@NonNull final SharpStream source, @NonNull final SharpStream target, + @Nullable final StreamInfo streamInfo) { if (!source.canRead() || !source.canRewind()) { throw new IllegalArgumentException("source stream must be readable and allows seeking"); } @@ -64,6 +72,7 @@ public class OggFromWebMWriter implements Closeable { this.source = source; this.output = target; + this.streamInfo = streamInfo; this.streamId = (int) System.currentTimeMillis(); @@ -272,25 +281,29 @@ public class OggFromWebMWriter implements Closeable { @Nullable private byte[] makeMetadata() { + Log.d("OggFromWebMWriter", "Downloading media with codec ID " + webmTrack.codecId); + if ("A_OPUS".equals(webmTrack.codecId)) { - final var commentFormat = "COMMENT=Downloaded using NewPipe on %s"; - final var commentStr = String.format(commentFormat, OffsetDateTime.now().toString()); - final var comment = commentStr.getBytes(); - final var head = ByteBuffer.allocate(20 + comment.length); - head.order(ByteOrder.LITTLE_ENDIAN); - head.put(new byte[]{ - // Byte order is LE, i.e. LSB first - 0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string - 0x00, 0x00, 0x00, 0x00, // vendor string of length 0 - 0x01, 0x00, 0x00, 0x00, // additional tags count + var metadata = ""; + metadata += String.format("COMMENT=Downloaded using NewPipe %s on %s\n", + BuildConfig.VERSION_NAME, + OffsetDateTime.now().toString()); + if (streamInfo != null) { + metadata += String.format("COMMENT=URL: %s\n", streamInfo.getUrl()); + metadata += String.format("GENRE=%s\n", streamInfo.getCategory()); + metadata += String.format("ARTIST=%s\n", streamInfo.getUploaderName()); + metadata += String.format("TITLE=%s\n", streamInfo.getName()); + metadata += String.format("DATE=%s\n", + streamInfo + .getUploadDate() + .getLocalDateTime() + .format(DateTimeFormatter.ISO_DATE)); + } - // + 4 bytes for the comment string length - // + N bytes for the comment string itself - }); - head.putInt(comment.length); - head.put(comment); + Log.d("OggFromWebMWriter", "Creating metadata header with this data:"); + Log.d("OggFromWebMWriter", metadata); - return head.array(); + return makeOpusTagsHeader(metadata); } else if ("A_VORBIS".equals(webmTrack.codecId)) { return new byte[]{ 0x03, // ¿¿¿??? @@ -304,6 +317,64 @@ public class OggFromWebMWriter implements Closeable { return null; } + /** + * 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. + * + * @param keyValue A key-value pair in the format "KEY=some value" + * @return The binary data of the encoded metadata tag + */ + private static byte[] makeOpusMetadataTag(final String keyValue) { + // Ensure the key is uppercase + final var delimiterIndex = keyValue.indexOf('='); + final var key = keyValue.substring(0, delimiterIndex).toUpperCase(); + final var value = keyValue.substring(delimiterIndex + 1); + final var reconstructedKeyValue = key + "=" + value; + + final var bytes = reconstructedKeyValue.getBytes(); + final var buf = ByteBuffer.allocate(4 + bytes.length); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.putInt(bytes.length); + buf.put(bytes); + return buf.array(); + } + + /** + * This returns a complete "OpusTags" header, created from the provided tags string. + *

+ * You probably want to use makeOpusMetadata(), which uses this function to create + * a header with sensible metadata filled in. + * + * @param keyValueLines A multiline string with each line containing a key-value pair + * in the format "KEY=some value". This may also be a blank string. + * @return The binary header + */ + private static byte[] makeOpusTagsHeader(@NonNull final String keyValueLines) { + final var tags = keyValueLines + .lines() + .map(String::trim) + .filter(s -> !s.isBlank()) + .map(OggFromWebMWriter::makeOpusMetadataTag) + .collect(Collectors.toUnmodifiableList()); + + final var tagsBytes = tags.stream().collect(Collectors.summingInt(arr -> arr.length)); + + // Fixed header fields + dynamic fields + final var byteCount = 16 + tagsBytes; + + final var head = ByteBuffer.allocate(byteCount); + head.order(ByteOrder.LITTLE_ENDIAN); + 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.putInt(tags.size()); // 4 bytes for tag count + tags.forEach(head::put); // dynamic amount of tag bytes + + return head.array(); + } + private void write(final ByteBuffer buffer) throws IOException { output.write(buffer.array(), 0, buffer.position()); buffer.position(0); 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 dc46ced5d..badb5f7ed 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,7 @@ class OggFromWebmDemuxer extends Postprocessing { @Override int process(SharpStream out, @NonNull SharpStream... sources) throws IOException { - OggFromWebMWriter demuxer = new OggFromWebMWriter(sources[0], out); + OggFromWebMWriter demuxer = new OggFromWebMWriter(sources[0], out, streamInfo); 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 7f5c85d27..1c9143252 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.util.Log; import androidx.annotation.NonNull; +import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.streams.io.SharpStream; import java.io.File; @@ -30,7 +31,8 @@ public abstract class Postprocessing implements Serializable { 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 Postprocessing getAlgorithm(@NonNull String algorithmName, String[] args) { + public static Postprocessing getAlgorithm(@NonNull String algorithmName, String[] args, + StreamInfo streamInfo) { Postprocessing instance; switch (algorithmName) { @@ -56,6 +58,7 @@ public abstract class Postprocessing implements Serializable { } instance.args = args; + instance.streamInfo = streamInfo; return instance; } @@ -75,8 +78,8 @@ public abstract class Postprocessing implements Serializable { */ private final String name; - private String[] args; + protected StreamInfo streamInfo; private transient DownloadMission mission; 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 45211211f..40df6e0f5 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.stream.StreamInfo; import org.schabi.newpipe.player.helper.LockManager; import org.schabi.newpipe.streams.io.StoredDirectoryHelper; import org.schabi.newpipe.streams.io.StoredFileHelper; @@ -80,6 +81,7 @@ public class DownloadManagerService extends Service { private static final String EXTRA_PARENT_PATH = "DownloadManagerService.extra.storageParentPath"; private static final String EXTRA_STORAGE_TAG = "DownloadManagerService.extra.storageTag"; private static final String EXTRA_RECOVERY_INFO = "DownloadManagerService.extra.recoveryInfo"; + private static final String EXTRA_STREAM_INFO = "DownloadManagerService.extra.streamInfo"; private static final String ACTION_RESET_DOWNLOAD_FINISHED = APPLICATION_ID + ".reset_download_finished"; private static final String ACTION_OPEN_DOWNLOADS_FINISHED = APPLICATION_ID + ".open_downloads_finished"; @@ -353,13 +355,13 @@ public class DownloadManagerService extends Service { * @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 source source url of the resource + * @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 */ public static void startMission(Context context, String[] urls, StoredFileHelper storage, - char kind, int threads, String source, String psName, + char kind, int threads, StreamInfo streamInfo, String psName, String[] psArgs, long nearLength, ArrayList recoveryInfo) { final Intent intent = new Intent(context, DownloadManagerService.class) @@ -367,14 +369,15 @@ public class DownloadManagerService extends Service { .putExtra(EXTRA_URLS, urls) .putExtra(EXTRA_KIND, kind) .putExtra(EXTRA_THREADS, threads) - .putExtra(EXTRA_SOURCE, source) + .putExtra(EXTRA_SOURCE, streamInfo.getUrl()) .putExtra(EXTRA_POSTPROCESSING_NAME, psName) .putExtra(EXTRA_POSTPROCESSING_ARGS, psArgs) .putExtra(EXTRA_NEAR_LENGTH, nearLength) .putExtra(EXTRA_RECOVERY_INFO, recoveryInfo) .putExtra(EXTRA_PARENT_PATH, storage.getParentUri()) .putExtra(EXTRA_PATH, storage.getUri()) - .putExtra(EXTRA_STORAGE_TAG, storage.getTag()); + .putExtra(EXTRA_STORAGE_TAG, storage.getTag()) + .putExtra(EXTRA_STREAM_INFO, streamInfo); context.startService(intent); } @@ -390,6 +393,7 @@ public class DownloadManagerService extends Service { String source = intent.getStringExtra(EXTRA_SOURCE); long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0); String tag = intent.getStringExtra(EXTRA_STORAGE_TAG); + StreamInfo streamInfo = (StreamInfo)intent.getSerializableExtra(EXTRA_STREAM_INFO); final var recovery = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_RECOVERY_INFO, MissionRecoveryInfo.class); Objects.requireNonNull(recovery); @@ -405,7 +409,7 @@ public class DownloadManagerService extends Service { if (psName == null) ps = null; else - ps = Postprocessing.getAlgorithm(psName, psArgs); + ps = Postprocessing.getAlgorithm(psName, psArgs, streamInfo); final DownloadMission mission = new DownloadMission(urls, storage, kind, ps); mission.threadCount = threads;