diff --git a/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt b/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt index 0fe251c16..9b8ee211e 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt @@ -12,15 +12,21 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import androidx.test.internal.runner.junit4.statement.UiThreadStatement import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.schabi.newpipe.R import org.schabi.newpipe.extractor.MediaFormat +import org.schabi.newpipe.extractor.downloader.Response import org.schabi.newpipe.extractor.stream.AudioStream import org.schabi.newpipe.extractor.stream.Stream import org.schabi.newpipe.extractor.stream.SubtitlesStream import org.schabi.newpipe.extractor.stream.VideoStream +import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper @MediumTest @RunWith(AndroidJUnit4::class) @@ -84,7 +90,7 @@ class StreamItemAdapterTest { @Test fun subtitleStreams_noIcon() { val adapter = StreamItemAdapter( - StreamItemAdapter.StreamSizeWrapper( + StreamItemAdapter.StreamInfoWrapper( (0 until 5).map { SubtitlesStream.Builder() .setContent("https://example.com", true) @@ -105,7 +111,7 @@ class StreamItemAdapterTest { @Test fun audioStreams_noIcon() { val adapter = StreamItemAdapter( - StreamItemAdapter.StreamSizeWrapper( + StreamItemAdapter.StreamInfoWrapper( (0 until 5).map { AudioStream.Builder() .setId(Stream.ID_UNKNOWN) @@ -123,12 +129,109 @@ class StreamItemAdapterTest { } } + @Test + fun retrieveMediaFormatFromFileTypeHeaders() { + val streams = getIncompleteAudioStreams(5) + val wrapper = StreamInfoWrapper(streams, context) + val retrieveMediaFormat = { stream: AudioStream, response: Response -> + StreamInfoWrapper.retrieveMediaFormatFromFileTypeHeaders(stream, wrapper, response) + } + val helper = AssertionHelper(streams, wrapper, retrieveMediaFormat) + + helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "mp3"))), 0) + helper.assertInvalidResponse(getResponse(mapOf(Pair("file-type", "mp0"))), 1) + + helper.assertValidResponse(getResponse(mapOf(Pair("x-amz-meta-file-type", "aiff"))), 2, MediaFormat.AIFF) + helper.assertValidResponse(getResponse(mapOf(Pair("file-type", "mp3"))), 3, MediaFormat.MP3) + } + + @Test + fun retrieveMediaFormatFromContentDispositionHeader() { + val streams = getIncompleteAudioStreams(11) + val wrapper = StreamInfoWrapper(streams, context) + val retrieveMediaFormat = { stream: AudioStream, response: Response -> + StreamInfoWrapper.retrieveMediaFormatFromContentDispositionHeader(stream, wrapper, response) + } + val helper = AssertionHelper(streams, wrapper, retrieveMediaFormat) + + helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "mp3"))), 0) + helper.assertInvalidResponse( + getResponse(mapOf(Pair("Content-Disposition", "filename=\"train.png\""))), 1 + ) + helper.assertInvalidResponse( + getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"data.csv\""))), 2 + ) + helper.assertInvalidResponse( + getResponse(mapOf(Pair("Content-Disposition", "form-data; filename=\"data.csv\""))), 3 + ) + helper.assertInvalidResponse( + getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"fieldName\"; filename*=\"filename.jpg\""))), 4 + ) + + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Disposition", "filename=\"train.ogg\""))), + 5, MediaFormat.OGG + ) + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Disposition", "some-form-data; filename=\"audio.flac\""))), + 6, MediaFormat.FLAC + ) + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.aiff\"; filename=\"audio.aiff\""))), + 7, MediaFormat.AIFF + ) + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"alien?\"; filename*=UTF-8''%CE%B1%CE%BB%CE%B9%CF%B5%CE%BD.m4a"))), + 8, MediaFormat.M4A + ) + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.mp3\"; filename=\"audio.opus\"; filename*=UTF-8''alien.opus"))), + 9, MediaFormat.OPUS + ) + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.mp3\"; filename=\"audio.opus\"; filename*=\"UTF-8''alien.opus\""))), + 10, MediaFormat.OPUS + ) + } + + @Test + fun retrieveMediaFormatFromContentTypeHeader() { + val streams = getIncompleteAudioStreams(12) + val wrapper = StreamInfoWrapper(streams, context) + val retrieveMediaFormat = { stream: AudioStream, response: Response -> + StreamInfoWrapper.retrieveMediaFormatFromContentTypeHeader(stream, wrapper, response) + } + val helper = AssertionHelper(streams, wrapper, retrieveMediaFormat) + + helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "984501"))), 0) + helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "audio/xyz"))), 1) + helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "mp3"))), 2) + helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "mp3"))), 3) + helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "audio/mpeg"))), 4) + helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "audio/aif"))), 5) + helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "whatever"))), 6) + helper.assertInvalidResponse(getResponse(mapOf()), 7) + + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Type", "audio/flac"))), 8, MediaFormat.FLAC + ) + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Type", "audio/wav"))), 9, MediaFormat.WAV + ) + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Type", "audio/opus"))), 10, MediaFormat.OPUS + ) + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Type", "audio/aiff"))), 11, MediaFormat.AIFF + ) + } + /** * @return a list of video streams, in which their video only property mirrors the provided * [videoOnly] vararg. */ private fun getVideoStreams(vararg videoOnly: Boolean) = - StreamItemAdapter.StreamSizeWrapper( + StreamItemAdapter.StreamInfoWrapper( videoOnly.map { VideoStream.Builder() .setId(Stream.ID_UNKNOWN) @@ -161,6 +264,19 @@ class StreamItemAdapterTest { } ) + private fun getIncompleteAudioStreams(size: Int): List { + val list = ArrayList(size) + for (i in 1..size) { + list.add( + AudioStream.Builder() + .setId(Stream.ID_UNKNOWN) + .setContent("https://example.com/$i", true) + .build() + ) + } + return list + } + /** * Checks whether the item at [position] in the [spinner] has the correct icon visibility when * it is shown in normal mode (selected) and in dropdown mode (user is choosing one of a list). @@ -196,11 +312,56 @@ class StreamItemAdapterTest { streams.forEachIndexed { index, stream -> val secondaryStreamHelper: SecondaryStreamHelper? = stream?.let { SecondaryStreamHelper( - StreamItemAdapter.StreamSizeWrapper(streams, context), + StreamItemAdapter.StreamInfoWrapper(streams, context), it ) } put(index, secondaryStreamHelper) } } + + private fun getResponse(headers: Map): Response { + val listHeaders = HashMap>() + headers.forEach { entry -> + listHeaders[entry.key] = listOf(entry.value) + } + return Response(200, null, listHeaders, "", "") + } + + /** + * Helper class for assertion related to extractions of [MediaFormat]s. + */ + class AssertionHelper( + private val streams: List, + private val wrapper: StreamInfoWrapper, + private val retrieveMediaFormat: (stream: T, response: Response) -> Boolean + ) { + + /** + * Assert that an invalid response does not result in wrongly extracted [MediaFormat]. + */ + fun assertInvalidResponse( + response: Response, + index: Int + ) { + assertFalse( + "invalid header returns valid value", retrieveMediaFormat(streams[index], response) + ) + assertNull("Media format extracted although stated otherwise", wrapper.getFormat(index)) + } + + /** + * Assert that a valid response results in correctly extracted and handled [MediaFormat]. + */ + fun assertValidResponse( + response: Response, + index: Int, + format: MediaFormat + ) { + assertTrue( + "header was not recognized", retrieveMediaFormat(streams[index], response) + ) + assertEquals("Wrong media format extracted", format, wrapper.getFormat(index)) + } + } } 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 9e05584da..9e9909e85 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -67,7 +67,7 @@ import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.SecondaryStreamHelper; import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; import org.schabi.newpipe.util.StreamItemAdapter; -import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper; +import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper; import org.schabi.newpipe.util.AudioTrackAdapter; import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper; import org.schabi.newpipe.util.ThemeHelper; @@ -97,9 +97,9 @@ public class DownloadDialog extends DialogFragment @State StreamInfo currentInfo; @State - StreamSizeWrapper wrappedVideoStreams; + StreamInfoWrapper wrappedVideoStreams; @State - StreamSizeWrapper wrappedSubtitleStreams; + StreamInfoWrapper wrappedSubtitleStreams; @State AudioTracksWrapper wrappedAudioTracks; @State @@ -187,8 +187,8 @@ public class DownloadDialog extends DialogFragment wrappedAudioTracks.size() > 1 ); - this.wrappedVideoStreams = new StreamSizeWrapper<>(videoStreams, context); - this.wrappedSubtitleStreams = new StreamSizeWrapper<>( + this.wrappedVideoStreams = new StreamInfoWrapper<>(videoStreams, context); + this.wrappedSubtitleStreams = new StreamInfoWrapper<>( getStreamsOfSpecifiedDelivery(info.getSubtitles(), PROGRESSIVE_HTTP), context); this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams); @@ -258,10 +258,10 @@ public class DownloadDialog extends DialogFragment * Update the displayed video streams based on the selected audio track. */ private void updateSecondaryStreams() { - final StreamSizeWrapper audioStreams = getWrappedAudioStreams(); + final StreamInfoWrapper audioStreams = getWrappedAudioStreams(); final var secondaryStreams = new SparseArrayCompat>(4); final List videoStreams = wrappedVideoStreams.getStreamsList(); - wrappedVideoStreams.resetSizes(); + wrappedVideoStreams.resetInfo(); for (int i = 0; i < videoStreams.size(); i++) { if (!videoStreams.get(i).isVideoOnly()) { @@ -396,7 +396,7 @@ public class DownloadDialog extends DialogFragment private void fetchStreamsSize() { disposables.clear(); - disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedVideoStreams) + disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedVideoStreams) .subscribe(result -> { if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.video_button) { @@ -406,7 +406,7 @@ public class DownloadDialog extends DialogFragment new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, "Downloading video stream size", currentInfo.getServiceId())))); - disposables.add(StreamSizeWrapper.fetchSizeForWrapper(getWrappedAudioStreams()) + disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(getWrappedAudioStreams()) .subscribe(result -> { if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) { @@ -416,7 +416,7 @@ public class DownloadDialog extends DialogFragment new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, "Downloading audio stream size", currentInfo.getServiceId())))); - disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedSubtitleStreams) + disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedSubtitleStreams) .subscribe(result -> { if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.subtitle_button) { @@ -724,9 +724,9 @@ public class DownloadDialog extends DialogFragment dialogBinding.subtitleButton.setEnabled(enabled); } - private StreamSizeWrapper getWrappedAudioStreams() { + private StreamInfoWrapper getWrappedAudioStreams() { if (selectedAudioTrackIndex < 0 || selectedAudioTrackIndex > wrappedAudioTracks.size()) { - return StreamSizeWrapper.empty(); + return StreamInfoWrapper.empty(); } return wrappedAudioTracks.getTracksList().get(selectedAudioTrackIndex); } @@ -766,7 +766,7 @@ public class DownloadDialog extends DialogFragment } private void showFailedDialog(@StringRes final int msg) { - assureCorrectAppLanguage(getContext()); + assureCorrectAppLanguage(requireContext()); new AlertDialog.Builder(context) .setTitle(R.string.general_error) .setMessage(msg) @@ -799,7 +799,7 @@ public class DownloadDialog extends DialogFragment filenameTmp += "opus"; } else if (format != null) { mimeTmp = format.mimeType; - filenameTmp += format.suffix; + filenameTmp += format.getSuffix(); } break; case R.id.video_button: @@ -808,7 +808,7 @@ public class DownloadDialog extends DialogFragment format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat(); if (format != null) { mimeTmp = format.mimeType; - filenameTmp += format.suffix; + filenameTmp += format.getSuffix(); } break; case R.id.subtitle_button: @@ -820,9 +820,9 @@ public class DownloadDialog extends DialogFragment } if (format == MediaFormat.TTML) { - filenameTmp += MediaFormat.SRT.suffix; + filenameTmp += MediaFormat.SRT.getSuffix(); } else if (format != null) { - filenameTmp += format.suffix; + filenameTmp += format.getSuffix(); } break; default: diff --git a/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java b/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java index 39a05acb3..90689052e 100644 --- a/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java @@ -13,7 +13,7 @@ import androidx.annotation.Nullable; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper; +import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper; import java.io.Serializable; import java.util.List; @@ -75,15 +75,15 @@ public class AudioTrackAdapter extends BaseAdapter { } public static class AudioTracksWrapper implements Serializable { - private final List> tracksList; + private final List> tracksList; public AudioTracksWrapper(@NonNull final List> groupedAudioStreams, @Nullable final Context context) { this.tracksList = groupedAudioStreams.stream().map(streams -> - new StreamSizeWrapper<>(streams, context)).collect(Collectors.toList()); + new StreamInfoWrapper<>(streams, context)).collect(Collectors.toList()); } - public List> getTracksList() { + public List> getTracksList() { return tracksList; } diff --git a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java index e7fd2d4a4..9415135cf 100644 --- a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java @@ -7,15 +7,15 @@ import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper; +import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper; import java.util.List; public class SecondaryStreamHelper { private final int position; - private final StreamSizeWrapper streams; + private final StreamInfoWrapper streams; - public SecondaryStreamHelper(@NonNull final StreamSizeWrapper streams, + public SecondaryStreamHelper(@NonNull final StreamInfoWrapper streams, final T selectedStream) { this.streams = streams; this.position = streams.getStreamsList().indexOf(selectedStream); diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java index 2eb63ff41..2eeb14b1b 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.util; +import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; + import android.content.Context; import android.view.LayoutInflater; import android.view.View; @@ -11,21 +13,25 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.collection.SparseArrayCompat; import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.downloader.Response; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.extractor.utils.Utils; import java.io.Serializable; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.Callable; +import java.util.stream.Collectors; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Single; @@ -41,7 +47,7 @@ import us.shandian.giga.util.Utility; */ public class StreamItemAdapter extends BaseAdapter { @NonNull - private final StreamSizeWrapper streamsWrapper; + private final StreamInfoWrapper streamsWrapper; @NonNull private final SparseArrayCompat> secondaryStreams; @@ -53,7 +59,7 @@ public class StreamItemAdapter extends BaseA private final boolean hasAnyVideoOnlyStreamWithNoSecondaryStream; public StreamItemAdapter( - @NonNull final StreamSizeWrapper streamsWrapper, + @NonNull final StreamInfoWrapper streamsWrapper, @NonNull final SparseArrayCompat> secondaryStreams ) { this.streamsWrapper = streamsWrapper; @@ -63,7 +69,7 @@ public class StreamItemAdapter extends BaseA checkHasAnyVideoOnlyStreamWithNoSecondaryStream(); } - public StreamItemAdapter(final StreamSizeWrapper streamsWrapper) { + public StreamItemAdapter(final StreamInfoWrapper streamsWrapper) { this(streamsWrapper, new SparseArrayCompat<>(0)); } @@ -121,7 +127,7 @@ public class StreamItemAdapter extends BaseA final TextView sizeView = convertView.findViewById(R.id.stream_size); final T stream = getItem(position); - final MediaFormat mediaFormat = stream.getFormat(); + final MediaFormat mediaFormat = streamsWrapper.getFormat(position); int woSoundIconVisibility = View.GONE; String qualityString; @@ -147,8 +153,6 @@ public class StreamItemAdapter extends BaseA final AudioStream audioStream = ((AudioStream) stream); if (audioStream.getAverageBitrate() > 0) { qualityString = audioStream.getAverageBitrate() + "kbps"; - } else if (mediaFormat != null) { - qualityString = mediaFormat.getName(); } else { qualityString = context.getString(R.string.unknown_quality); } @@ -221,46 +225,58 @@ public class StreamItemAdapter extends BaseA * * @param the stream type's class extending {@link Stream} */ - public static class StreamSizeWrapper implements Serializable { - private static final StreamSizeWrapper EMPTY = - new StreamSizeWrapper<>(Collections.emptyList(), null); + public static class StreamInfoWrapper implements Serializable { + private static final StreamInfoWrapper EMPTY = + new StreamInfoWrapper<>(Collections.emptyList(), null); private static final int SIZE_UNSET = -2; private final List streamsList; private final long[] streamSizes; + private final MediaFormat[] streamFormats; private final String unknownSize; - public StreamSizeWrapper(@NonNull final List streamList, + public StreamInfoWrapper(@NonNull final List streamList, @Nullable final Context context) { this.streamsList = streamList; this.streamSizes = new long[streamsList.size()]; this.unknownSize = context == null ? "--.-" : context.getString(R.string.unknown_content); - - resetSizes(); + this.streamFormats = new MediaFormat[streamsList.size()]; + resetInfo(); } /** - * Helper method to fetch the sizes of all the streams in a wrapper. + * Helper method to fetch the sizes and missing media formats + * of all the streams in a wrapper. * * @param the stream type's class extending {@link Stream} * @param streamsWrapper the wrapper * @return a {@link Single} that returns a boolean indicating if any elements were changed */ @NonNull - public static Single fetchSizeForWrapper( - final StreamSizeWrapper streamsWrapper) { + public static Single fetchMoreInfoForWrapper( + final StreamInfoWrapper streamsWrapper) { final Callable fetchAndSet = () -> { boolean hasChanged = false; for (final X stream : streamsWrapper.getStreamsList()) { - if (streamsWrapper.getSizeInBytes(stream) > SIZE_UNSET) { + final boolean changeSize = streamsWrapper.getSizeInBytes(stream) <= SIZE_UNSET; + final boolean changeFormat = stream.getFormat() == null; + if (!changeSize && !changeFormat) { continue; } - - final long contentLength = DownloaderImpl.getInstance().getContentLength( - stream.getContent()); - streamsWrapper.setSize(stream, contentLength); - hasChanged = true; + final Response response = DownloaderImpl.getInstance() + .head(stream.getContent()); + if (changeSize) { + final String contentLength = response.getHeader("Content-Length"); + if (!isNullOrEmpty(contentLength)) { + streamsWrapper.setSize(stream, Long.parseLong(contentLength)); + hasChanged = true; + } + } + if (changeFormat) { + hasChanged = retrieveMediaFormat(stream, streamsWrapper, response) + || hasChanged; + } } return hasChanged; }; @@ -271,13 +287,149 @@ public class StreamItemAdapter extends BaseA .onErrorReturnItem(true); } - public void resetSizes() { - Arrays.fill(streamSizes, SIZE_UNSET); + /** + * Try to retrieve the {@link MediaFormat} for a stream from the request headers. + * + * @param the stream type to get the {@link MediaFormat} for + * @param stream the stream to find the {@link MediaFormat} for + * @param streamsWrapper the wrapper to store the found {@link MediaFormat} in + * @param response the response of the head request for the given stream + * @return {@code true} if the media format could be retrieved; {@code false} otherwise + */ + @VisibleForTesting + public static boolean retrieveMediaFormat( + @NonNull final X stream, + @NonNull final StreamInfoWrapper streamsWrapper, + @NonNull final Response response) { + return retrieveMediaFormatFromFileTypeHeaders(stream, streamsWrapper, response) + || retrieveMediaFormatFromContentDispositionHeader( + stream, streamsWrapper, response) + || retrieveMediaFormatFromContentTypeHeader(stream, streamsWrapper, response); } - public static StreamSizeWrapper empty() { + @VisibleForTesting + public static boolean retrieveMediaFormatFromFileTypeHeaders( + @NonNull final X stream, + @NonNull final StreamInfoWrapper streamsWrapper, + @NonNull final Response response) { + // try to use additional headers from CDNs or servers, + // e.g. x-amz-meta-file-type (e.g. for SoundCloud) + final List keys = response.responseHeaders().keySet().stream() + .filter(k -> k.endsWith("file-type")).collect(Collectors.toList()); + if (!keys.isEmpty()) { + for (final String key : keys) { + final String suffix = response.getHeader(key); + final MediaFormat format = MediaFormat.getFromSuffix(suffix); + if (format != null) { + streamsWrapper.setFormat(stream, format); + return true; + } + } + } + return false; + } + + /** + *

Retrieve a {@link MediaFormat} from a HTTP Content-Disposition header + * for a stream and store the info in a wrapper.

+ * @see + * + * mdn Web Docs for the HTTP Content-Disposition Header + * @param stream the stream to get the {@link MediaFormat} for + * @param streamsWrapper the wrapper to store the {@link MediaFormat} in + * @param response the response to get the Content-Disposition header from + * @return {@code true} if the {@link MediaFormat} could be retrieved from the response; + * otherwise {@code false} + * @param + */ + @VisibleForTesting + public static boolean retrieveMediaFormatFromContentDispositionHeader( + @NonNull final X stream, + @NonNull final StreamInfoWrapper streamsWrapper, + @NonNull final Response response) { + // parse the Content-Disposition header, + // see + // there can be two filename directives + String contentDisposition = response.getHeader("Content-Disposition"); + if (contentDisposition == null) { + return false; + } + try { + contentDisposition = Utils.decodeUrlUtf8(contentDisposition); + final String[] parts = contentDisposition.split(";"); + for (String part : parts) { + final String fileName; + part = part.trim(); + + // extract the filename + if (part.startsWith("filename=")) { + // remove directive and decode + fileName = Utils.decodeUrlUtf8(part.substring(9)); + } else if (part.startsWith("filename*=")) { + fileName = Utils.decodeUrlUtf8(part.substring(10)); + } else { + continue; + } + + // extract the file extension / suffix + final String[] p = fileName.split("\\."); + String suffix = p[p.length - 1]; + if (suffix.endsWith("\"") || suffix.endsWith("'")) { + // remove trailing quotes if present, end index is exclusive + suffix = suffix.substring(0, suffix.length() - 1); + } + + // get the corresponding media format + final MediaFormat format = MediaFormat.getFromSuffix(suffix); + if (format != null) { + streamsWrapper.setFormat(stream, format); + return true; + } + } + } catch (final Exception ignored) { + // fail silently + } + return false; + } + + @VisibleForTesting + public static boolean retrieveMediaFormatFromContentTypeHeader( + @NonNull final X stream, + @NonNull final StreamInfoWrapper streamsWrapper, + @NonNull final Response response) { + // try to get the format by content type + // some mime types are not unique for every format, those are omitted + final String contentTypeHeader = response.getHeader("Content-Type"); + if (contentTypeHeader == null) { + return false; + } + + @Nullable MediaFormat foundFormat = null; + for (final MediaFormat format : MediaFormat.getAllFromMimeType(contentTypeHeader)) { + if (foundFormat == null) { + foundFormat = format; + } else if (foundFormat.id != format.id) { + return false; + } + } + if (foundFormat != null) { + streamsWrapper.setFormat(stream, foundFormat); + return true; + } + return false; + } + + public void resetInfo() { + Arrays.fill(streamSizes, SIZE_UNSET); + for (int i = 0; i < streamsList.size(); i++) { + streamFormats[i] = streamsList.get(i) == null // test for invalid streams + ? null : streamsList.get(i).getFormat(); + } + } + + public static StreamInfoWrapper empty() { //noinspection unchecked - return (StreamSizeWrapper) EMPTY; + return (StreamInfoWrapper) EMPTY; } public List getStreamsList() { @@ -306,5 +458,13 @@ public class StreamItemAdapter extends BaseA public void setSize(final T stream, final long sizeInBytes) { streamSizes[streamsList.indexOf(stream)] = sizeInBytes; } + + public MediaFormat getFormat(final int streamIndex) { + return streamFormats[streamIndex]; + } + + public void setFormat(final T stream, final MediaFormat format) { + streamFormats[streamsList.indexOf(stream)] = format; + } } }