Merge pull request #10165 from TeamNewPipe/fix/media-format

Fix downloads of streams with missing MediaFormat
This commit is contained in:
Stypox 2023-09-19 15:54:12 +02:00 committed by GitHub
commit 725c18eada
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 374 additions and 53 deletions

View File

@ -12,15 +12,21 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest import androidx.test.filters.MediumTest
import androidx.test.internal.runner.junit4.statement.UiThreadStatement import androidx.test.internal.runner.junit4.statement.UiThreadStatement
import org.junit.Assert 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.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.MediaFormat 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.AudioStream
import org.schabi.newpipe.extractor.stream.Stream import org.schabi.newpipe.extractor.stream.Stream
import org.schabi.newpipe.extractor.stream.SubtitlesStream import org.schabi.newpipe.extractor.stream.SubtitlesStream
import org.schabi.newpipe.extractor.stream.VideoStream import org.schabi.newpipe.extractor.stream.VideoStream
import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper
@MediumTest @MediumTest
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@ -84,7 +90,7 @@ class StreamItemAdapterTest {
@Test @Test
fun subtitleStreams_noIcon() { fun subtitleStreams_noIcon() {
val adapter = StreamItemAdapter<SubtitlesStream, Stream>( val adapter = StreamItemAdapter<SubtitlesStream, Stream>(
StreamItemAdapter.StreamSizeWrapper( StreamItemAdapter.StreamInfoWrapper(
(0 until 5).map { (0 until 5).map {
SubtitlesStream.Builder() SubtitlesStream.Builder()
.setContent("https://example.com", true) .setContent("https://example.com", true)
@ -105,7 +111,7 @@ class StreamItemAdapterTest {
@Test @Test
fun audioStreams_noIcon() { fun audioStreams_noIcon() {
val adapter = StreamItemAdapter<AudioStream, Stream>( val adapter = StreamItemAdapter<AudioStream, Stream>(
StreamItemAdapter.StreamSizeWrapper( StreamItemAdapter.StreamInfoWrapper(
(0 until 5).map { (0 until 5).map {
AudioStream.Builder() AudioStream.Builder()
.setId(Stream.ID_UNKNOWN) .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 * @return a list of video streams, in which their video only property mirrors the provided
* [videoOnly] vararg. * [videoOnly] vararg.
*/ */
private fun getVideoStreams(vararg videoOnly: Boolean) = private fun getVideoStreams(vararg videoOnly: Boolean) =
StreamItemAdapter.StreamSizeWrapper( StreamItemAdapter.StreamInfoWrapper(
videoOnly.map { videoOnly.map {
VideoStream.Builder() VideoStream.Builder()
.setId(Stream.ID_UNKNOWN) .setId(Stream.ID_UNKNOWN)
@ -161,6 +264,19 @@ class StreamItemAdapterTest {
} }
) )
private fun getIncompleteAudioStreams(size: Int): List<AudioStream> {
val list = ArrayList<AudioStream>(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 * 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). * 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 -> streams.forEachIndexed { index, stream ->
val secondaryStreamHelper: SecondaryStreamHelper<T>? = stream?.let { val secondaryStreamHelper: SecondaryStreamHelper<T>? = stream?.let {
SecondaryStreamHelper( SecondaryStreamHelper(
StreamItemAdapter.StreamSizeWrapper(streams, context), StreamItemAdapter.StreamInfoWrapper(streams, context),
it it
) )
} }
put(index, secondaryStreamHelper) put(index, secondaryStreamHelper)
} }
} }
private fun getResponse(headers: Map<String, String>): Response {
val listHeaders = HashMap<String, List<String>>()
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<T : Stream>(
private val streams: List<T>,
private val wrapper: StreamInfoWrapper<T>,
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))
}
}
} }

View File

@ -67,7 +67,7 @@ import org.schabi.newpipe.util.PermissionHelper;
import org.schabi.newpipe.util.SecondaryStreamHelper; import org.schabi.newpipe.util.SecondaryStreamHelper;
import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
import org.schabi.newpipe.util.StreamItemAdapter; 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;
import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper; import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper;
import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.ThemeHelper;
@ -97,9 +97,9 @@ public class DownloadDialog extends DialogFragment
@State @State
StreamInfo currentInfo; StreamInfo currentInfo;
@State @State
StreamSizeWrapper<VideoStream> wrappedVideoStreams; StreamInfoWrapper<VideoStream> wrappedVideoStreams;
@State @State
StreamSizeWrapper<SubtitlesStream> wrappedSubtitleStreams; StreamInfoWrapper<SubtitlesStream> wrappedSubtitleStreams;
@State @State
AudioTracksWrapper wrappedAudioTracks; AudioTracksWrapper wrappedAudioTracks;
@State @State
@ -187,8 +187,8 @@ public class DownloadDialog extends DialogFragment
wrappedAudioTracks.size() > 1 wrappedAudioTracks.size() > 1
); );
this.wrappedVideoStreams = new StreamSizeWrapper<>(videoStreams, context); this.wrappedVideoStreams = new StreamInfoWrapper<>(videoStreams, context);
this.wrappedSubtitleStreams = new StreamSizeWrapper<>( this.wrappedSubtitleStreams = new StreamInfoWrapper<>(
getStreamsOfSpecifiedDelivery(info.getSubtitles(), PROGRESSIVE_HTTP), context); getStreamsOfSpecifiedDelivery(info.getSubtitles(), PROGRESSIVE_HTTP), context);
this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams); 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. * Update the displayed video streams based on the selected audio track.
*/ */
private void updateSecondaryStreams() { private void updateSecondaryStreams() {
final StreamSizeWrapper<AudioStream> audioStreams = getWrappedAudioStreams(); final StreamInfoWrapper<AudioStream> audioStreams = getWrappedAudioStreams();
final var secondaryStreams = new SparseArrayCompat<SecondaryStreamHelper<AudioStream>>(4); final var secondaryStreams = new SparseArrayCompat<SecondaryStreamHelper<AudioStream>>(4);
final List<VideoStream> videoStreams = wrappedVideoStreams.getStreamsList(); final List<VideoStream> videoStreams = wrappedVideoStreams.getStreamsList();
wrappedVideoStreams.resetSizes(); wrappedVideoStreams.resetInfo();
for (int i = 0; i < videoStreams.size(); i++) { for (int i = 0; i < videoStreams.size(); i++) {
if (!videoStreams.get(i).isVideoOnly()) { if (!videoStreams.get(i).isVideoOnly()) {
@ -396,7 +396,7 @@ public class DownloadDialog extends DialogFragment
private void fetchStreamsSize() { private void fetchStreamsSize() {
disposables.clear(); disposables.clear();
disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedVideoStreams) disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedVideoStreams)
.subscribe(result -> { .subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
== R.id.video_button) { == R.id.video_button) {
@ -406,7 +406,7 @@ public class DownloadDialog extends DialogFragment
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
"Downloading video stream size", "Downloading video stream size",
currentInfo.getServiceId())))); currentInfo.getServiceId()))));
disposables.add(StreamSizeWrapper.fetchSizeForWrapper(getWrappedAudioStreams()) disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(getWrappedAudioStreams())
.subscribe(result -> { .subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
== R.id.audio_button) { == R.id.audio_button) {
@ -416,7 +416,7 @@ public class DownloadDialog extends DialogFragment
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
"Downloading audio stream size", "Downloading audio stream size",
currentInfo.getServiceId())))); currentInfo.getServiceId()))));
disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedSubtitleStreams) disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedSubtitleStreams)
.subscribe(result -> { .subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
== R.id.subtitle_button) { == R.id.subtitle_button) {
@ -724,9 +724,9 @@ public class DownloadDialog extends DialogFragment
dialogBinding.subtitleButton.setEnabled(enabled); dialogBinding.subtitleButton.setEnabled(enabled);
} }
private StreamSizeWrapper<AudioStream> getWrappedAudioStreams() { private StreamInfoWrapper<AudioStream> getWrappedAudioStreams() {
if (selectedAudioTrackIndex < 0 || selectedAudioTrackIndex > wrappedAudioTracks.size()) { if (selectedAudioTrackIndex < 0 || selectedAudioTrackIndex > wrappedAudioTracks.size()) {
return StreamSizeWrapper.empty(); return StreamInfoWrapper.empty();
} }
return wrappedAudioTracks.getTracksList().get(selectedAudioTrackIndex); return wrappedAudioTracks.getTracksList().get(selectedAudioTrackIndex);
} }
@ -766,7 +766,7 @@ public class DownloadDialog extends DialogFragment
} }
private void showFailedDialog(@StringRes final int msg) { private void showFailedDialog(@StringRes final int msg) {
assureCorrectAppLanguage(getContext()); assureCorrectAppLanguage(requireContext());
new AlertDialog.Builder(context) new AlertDialog.Builder(context)
.setTitle(R.string.general_error) .setTitle(R.string.general_error)
.setMessage(msg) .setMessage(msg)
@ -799,7 +799,7 @@ public class DownloadDialog extends DialogFragment
filenameTmp += "opus"; filenameTmp += "opus";
} else if (format != null) { } else if (format != null) {
mimeTmp = format.mimeType; mimeTmp = format.mimeType;
filenameTmp += format.suffix; filenameTmp += format.getSuffix();
} }
break; break;
case R.id.video_button: case R.id.video_button:
@ -808,7 +808,7 @@ public class DownloadDialog extends DialogFragment
format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat(); format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat();
if (format != null) { if (format != null) {
mimeTmp = format.mimeType; mimeTmp = format.mimeType;
filenameTmp += format.suffix; filenameTmp += format.getSuffix();
} }
break; break;
case R.id.subtitle_button: case R.id.subtitle_button:
@ -820,9 +820,9 @@ public class DownloadDialog extends DialogFragment
} }
if (format == MediaFormat.TTML) { if (format == MediaFormat.TTML) {
filenameTmp += MediaFormat.SRT.suffix; filenameTmp += MediaFormat.SRT.getSuffix();
} else if (format != null) { } else if (format != null) {
filenameTmp += format.suffix; filenameTmp += format.getSuffix();
} }
break; break;
default: default:

View File

@ -13,7 +13,7 @@ import androidx.annotation.Nullable;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.stream.AudioStream; 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.io.Serializable;
import java.util.List; import java.util.List;
@ -75,15 +75,15 @@ public class AudioTrackAdapter extends BaseAdapter {
} }
public static class AudioTracksWrapper implements Serializable { public static class AudioTracksWrapper implements Serializable {
private final List<StreamSizeWrapper<AudioStream>> tracksList; private final List<StreamInfoWrapper<AudioStream>> tracksList;
public AudioTracksWrapper(@NonNull final List<List<AudioStream>> groupedAudioStreams, public AudioTracksWrapper(@NonNull final List<List<AudioStream>> groupedAudioStreams,
@Nullable final Context context) { @Nullable final Context context) {
this.tracksList = groupedAudioStreams.stream().map(streams -> this.tracksList = groupedAudioStreams.stream().map(streams ->
new StreamSizeWrapper<>(streams, context)).collect(Collectors.toList()); new StreamInfoWrapper<>(streams, context)).collect(Collectors.toList());
} }
public List<StreamSizeWrapper<AudioStream>> getTracksList() { public List<StreamInfoWrapper<AudioStream>> getTracksList() {
return tracksList; return tracksList;
} }

View File

@ -7,15 +7,15 @@ import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.Stream;
import org.schabi.newpipe.extractor.stream.VideoStream; 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; import java.util.List;
public class SecondaryStreamHelper<T extends Stream> { public class SecondaryStreamHelper<T extends Stream> {
private final int position; private final int position;
private final StreamSizeWrapper<T> streams; private final StreamInfoWrapper<T> streams;
public SecondaryStreamHelper(@NonNull final StreamSizeWrapper<T> streams, public SecondaryStreamHelper(@NonNull final StreamInfoWrapper<T> streams,
final T selectedStream) { final T selectedStream) {
this.streams = streams; this.streams = streams;
this.position = streams.getStreamsList().indexOf(selectedStream); this.position = streams.getStreamsList().indexOf(selectedStream);

View File

@ -1,5 +1,7 @@
package org.schabi.newpipe.util; package org.schabi.newpipe.util;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
import android.content.Context; import android.content.Context;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
@ -11,21 +13,25 @@ import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.collection.SparseArrayCompat; import androidx.collection.SparseArrayCompat;
import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.DownloaderImpl;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.MediaFormat; 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.AudioStream;
import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.Stream;
import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.SubtitlesStream;
import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.extractor.utils.Utils;
import java.io.Serializable; import java.io.Serializable;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.concurrent.Callable; import java.util.concurrent.Callable;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.core.Single;
@ -41,7 +47,7 @@ import us.shandian.giga.util.Utility;
*/ */
public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseAdapter { public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseAdapter {
@NonNull @NonNull
private final StreamSizeWrapper<T> streamsWrapper; private final StreamInfoWrapper<T> streamsWrapper;
@NonNull @NonNull
private final SparseArrayCompat<SecondaryStreamHelper<U>> secondaryStreams; private final SparseArrayCompat<SecondaryStreamHelper<U>> secondaryStreams;
@ -53,7 +59,7 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
private final boolean hasAnyVideoOnlyStreamWithNoSecondaryStream; private final boolean hasAnyVideoOnlyStreamWithNoSecondaryStream;
public StreamItemAdapter( public StreamItemAdapter(
@NonNull final StreamSizeWrapper<T> streamsWrapper, @NonNull final StreamInfoWrapper<T> streamsWrapper,
@NonNull final SparseArrayCompat<SecondaryStreamHelper<U>> secondaryStreams @NonNull final SparseArrayCompat<SecondaryStreamHelper<U>> secondaryStreams
) { ) {
this.streamsWrapper = streamsWrapper; this.streamsWrapper = streamsWrapper;
@ -63,7 +69,7 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
checkHasAnyVideoOnlyStreamWithNoSecondaryStream(); checkHasAnyVideoOnlyStreamWithNoSecondaryStream();
} }
public StreamItemAdapter(final StreamSizeWrapper<T> streamsWrapper) { public StreamItemAdapter(final StreamInfoWrapper<T> streamsWrapper) {
this(streamsWrapper, new SparseArrayCompat<>(0)); this(streamsWrapper, new SparseArrayCompat<>(0));
} }
@ -121,7 +127,7 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
final TextView sizeView = convertView.findViewById(R.id.stream_size); final TextView sizeView = convertView.findViewById(R.id.stream_size);
final T stream = getItem(position); final T stream = getItem(position);
final MediaFormat mediaFormat = stream.getFormat(); final MediaFormat mediaFormat = streamsWrapper.getFormat(position);
int woSoundIconVisibility = View.GONE; int woSoundIconVisibility = View.GONE;
String qualityString; String qualityString;
@ -147,8 +153,6 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
final AudioStream audioStream = ((AudioStream) stream); final AudioStream audioStream = ((AudioStream) stream);
if (audioStream.getAverageBitrate() > 0) { if (audioStream.getAverageBitrate() > 0) {
qualityString = audioStream.getAverageBitrate() + "kbps"; qualityString = audioStream.getAverageBitrate() + "kbps";
} else if (mediaFormat != null) {
qualityString = mediaFormat.getName();
} else { } else {
qualityString = context.getString(R.string.unknown_quality); qualityString = context.getString(R.string.unknown_quality);
} }
@ -221,46 +225,58 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
* *
* @param <T> the stream type's class extending {@link Stream} * @param <T> the stream type's class extending {@link Stream}
*/ */
public static class StreamSizeWrapper<T extends Stream> implements Serializable { public static class StreamInfoWrapper<T extends Stream> implements Serializable {
private static final StreamSizeWrapper<Stream> EMPTY = private static final StreamInfoWrapper<Stream> EMPTY =
new StreamSizeWrapper<>(Collections.emptyList(), null); new StreamInfoWrapper<>(Collections.emptyList(), null);
private static final int SIZE_UNSET = -2; private static final int SIZE_UNSET = -2;
private final List<T> streamsList; private final List<T> streamsList;
private final long[] streamSizes; private final long[] streamSizes;
private final MediaFormat[] streamFormats;
private final String unknownSize; private final String unknownSize;
public StreamSizeWrapper(@NonNull final List<T> streamList, public StreamInfoWrapper(@NonNull final List<T> streamList,
@Nullable final Context context) { @Nullable final Context context) {
this.streamsList = streamList; this.streamsList = streamList;
this.streamSizes = new long[streamsList.size()]; this.streamSizes = new long[streamsList.size()];
this.unknownSize = context == null this.unknownSize = context == null
? "--.-" : context.getString(R.string.unknown_content); ? "--.-" : context.getString(R.string.unknown_content);
this.streamFormats = new MediaFormat[streamsList.size()];
resetSizes(); 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 <X> the stream type's class extending {@link Stream} * @param <X> the stream type's class extending {@link Stream}
* @param streamsWrapper the wrapper * @param streamsWrapper the wrapper
* @return a {@link Single} that returns a boolean indicating if any elements were changed * @return a {@link Single} that returns a boolean indicating if any elements were changed
*/ */
@NonNull @NonNull
public static <X extends Stream> Single<Boolean> fetchSizeForWrapper( public static <X extends Stream> Single<Boolean> fetchMoreInfoForWrapper(
final StreamSizeWrapper<X> streamsWrapper) { final StreamInfoWrapper<X> streamsWrapper) {
final Callable<Boolean> fetchAndSet = () -> { final Callable<Boolean> fetchAndSet = () -> {
boolean hasChanged = false; boolean hasChanged = false;
for (final X stream : streamsWrapper.getStreamsList()) { 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; continue;
} }
final Response response = DownloaderImpl.getInstance()
final long contentLength = DownloaderImpl.getInstance().getContentLength( .head(stream.getContent());
stream.getContent()); if (changeSize) {
streamsWrapper.setSize(stream, contentLength); final String contentLength = response.getHeader("Content-Length");
hasChanged = true; if (!isNullOrEmpty(contentLength)) {
streamsWrapper.setSize(stream, Long.parseLong(contentLength));
hasChanged = true;
}
}
if (changeFormat) {
hasChanged = retrieveMediaFormat(stream, streamsWrapper, response)
|| hasChanged;
}
} }
return hasChanged; return hasChanged;
}; };
@ -271,13 +287,149 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
.onErrorReturnItem(true); .onErrorReturnItem(true);
} }
public void resetSizes() { /**
Arrays.fill(streamSizes, SIZE_UNSET); * Try to retrieve the {@link MediaFormat} for a stream from the request headers.
*
* @param <X> 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 <X extends Stream> boolean retrieveMediaFormat(
@NonNull final X stream,
@NonNull final StreamInfoWrapper<X> streamsWrapper,
@NonNull final Response response) {
return retrieveMediaFormatFromFileTypeHeaders(stream, streamsWrapper, response)
|| retrieveMediaFormatFromContentDispositionHeader(
stream, streamsWrapper, response)
|| retrieveMediaFormatFromContentTypeHeader(stream, streamsWrapper, response);
} }
public static <X extends Stream> StreamSizeWrapper<X> empty() { @VisibleForTesting
public static <X extends Stream> boolean retrieveMediaFormatFromFileTypeHeaders(
@NonNull final X stream,
@NonNull final StreamInfoWrapper<X> 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<String> 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;
}
/**
* <p>Retrieve a {@link MediaFormat} from a HTTP Content-Disposition header
* for a stream and store the info in a wrapper.</p>
* @see
* <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition">
* mdn Web Docs for the HTTP Content-Disposition Header</a>
* @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 <X>
*/
@VisibleForTesting
public static <X extends Stream> boolean retrieveMediaFormatFromContentDispositionHeader(
@NonNull final X stream,
@NonNull final StreamInfoWrapper<X> 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 <X extends Stream> boolean retrieveMediaFormatFromContentTypeHeader(
@NonNull final X stream,
@NonNull final StreamInfoWrapper<X> 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 <X extends Stream> StreamInfoWrapper<X> empty() {
//noinspection unchecked //noinspection unchecked
return (StreamSizeWrapper<X>) EMPTY; return (StreamInfoWrapper<X>) EMPTY;
} }
public List<T> getStreamsList() { public List<T> getStreamsList() {
@ -306,5 +458,13 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
public void setSize(final T stream, final long sizeInBytes) { public void setSize(final T stream, final long sizeInBytes) {
streamSizes[streamsList.indexOf(stream)] = 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;
}
} }
} }