diff --git a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java deleted file mode 100644 index 409fcb30cbd..00000000000 --- a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java +++ /dev/null @@ -1,880 +0,0 @@ -package org.schabi.newpipe.util; - -import static org.schabi.newpipe.extractor.ServiceList.YouTube; - -import android.content.Context; -import android.content.SharedPreferences; -import android.content.res.Resources; -import android.net.ConnectivityManager; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.core.content.ContextCompat; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.MediaFormat; -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.AudioTrackType; -import org.schabi.newpipe.extractor.stream.DeliveryMethod; -import org.schabi.newpipe.extractor.stream.Stream; -import org.schabi.newpipe.extractor.stream.VideoStream; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.Set; -import java.util.function.Predicate; -import java.util.stream.Collectors; - -public final class ListHelper { - // Video format in order of quality. 0=lowest quality, n=highest quality - private static final List VIDEO_FORMAT_QUALITY_RANKING = - List.of(MediaFormat.v3GPP, MediaFormat.WEBM, MediaFormat.MPEG_4); - - // Audio format in order of quality. 0=lowest quality, n=highest quality - private static final List AUDIO_FORMAT_QUALITY_RANKING = - List.of(MediaFormat.MP3, MediaFormat.WEBMA, MediaFormat.M4A); - // Audio format in order of efficiency. 0=least efficient, n=most efficient - private static final List AUDIO_FORMAT_EFFICIENCY_RANKING = - List.of(MediaFormat.MP3, MediaFormat.M4A, MediaFormat.WEBMA); - // Use a Set for better performance - private static final Set HIGH_RESOLUTION_LIST = Set.of("1440p", "2160p"); - // Audio track types in order of priority. 0=lowest, n=highest - private static final List AUDIO_TRACK_TYPE_RANKING = - List.of(AudioTrackType.DESCRIPTIVE, AudioTrackType.SECONDARY, AudioTrackType.DUBBED, - AudioTrackType.ORIGINAL); - // Audio track types in order of priority when descriptive audio is preferred. - private static final List AUDIO_TRACK_TYPE_RANKING_DESCRIPTIVE = - List.of(AudioTrackType.SECONDARY, AudioTrackType.DUBBED, AudioTrackType.ORIGINAL, - AudioTrackType.DESCRIPTIVE); - - /** - * List of supported YouTube Itag ids. - * The original order is kept. - * @see {@link org.schabi.newpipe.extractor.services.youtube.ItagItem#ITAG_LIST} - */ - private static final List SUPPORTED_ITAG_IDS = - List.of( - 17, 36, // video v3GPP - 18, 34, 35, 59, 78, 22, 37, 38, // video MPEG4 - 43, 44, 45, 46, // video webm - 171, 172, 139, 140, 141, 249, 250, 251, // audio - 160, 133, 134, 135, 212, 136, 298, 137, 299, 266, // video only - 278, 242, 243, 244, 245, 246, 247, 248, 271, 272, 302, 303, 308, 313, 315 - ); - - private ListHelper() { } - - /** - * @param context Android app context - * @param videoStreams list of the video streams to check - * @return index of the video stream with the default index - * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) - */ - public static int getDefaultResolutionIndex(final Context context, - final List videoStreams) { - final String defaultResolution = computeDefaultResolution(context, - R.string.default_resolution_key, R.string.default_resolution_value); - return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams); - } - - /** - * @param context Android app context - * @param videoStreams list of the video streams to check - * @param defaultResolution the default resolution to look for - * @return index of the video stream with the default index - * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) - */ - public static int getResolutionIndex(final Context context, - final List videoStreams, - final String defaultResolution) { - return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams); - } - - /** - * @param context Android app context - * @param videoStreams list of the video streams to check - * @return index of the video stream with the default index - * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) - */ - public static int getPopupDefaultResolutionIndex(final Context context, - final List videoStreams) { - final String defaultResolution = computeDefaultResolution(context, - R.string.default_popup_resolution_key, R.string.default_popup_resolution_value); - return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams); - } - - /** - * @param context Android app context - * @param videoStreams list of the video streams to check - * @param defaultResolution the default resolution to look for - * @return index of the video stream with the default index - * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) - */ - public static int getPopupResolutionIndex(final Context context, - final List videoStreams, - final String defaultResolution) { - return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams); - } - - public static int getDefaultAudioFormat(final Context context, - final List audioStreams) { - return getAudioIndexByHighestRank(audioStreams, - getAudioTrackComparator(context).thenComparing(getAudioFormatComparator(context))); - } - - public static int getDefaultAudioTrackGroup(final Context context, - final List> groupedAudioStreams) { - if (groupedAudioStreams == null || groupedAudioStreams.isEmpty()) { - return -1; - } - - final Comparator cmp = getAudioTrackComparator(context); - final List highestRanked = groupedAudioStreams.stream() - .max((o1, o2) -> cmp.compare(o1.get(0), o2.get(0))) - .orElse(null); - return groupedAudioStreams.indexOf(highestRanked); - } - - public static int getAudioFormatIndex(final Context context, - final List audioStreams, - @Nullable final String trackId) { - if (trackId != null) { - for (int i = 0; i < audioStreams.size(); i++) { - final AudioStream s = audioStreams.get(i); - if (s.getAudioTrackId() != null - && s.getAudioTrackId().equals(trackId)) { - return i; - } - } - } - return getDefaultAudioFormat(context, audioStreams); - } - - /** - * Return a {@link Stream} list which uses the given delivery method from a {@link Stream} - * list. - * - * @param streamList the original {@link Stream stream} list - * @param deliveryMethod the {@link DeliveryMethod delivery method} - * @param the item type's class that extends {@link Stream} - * @return a {@link Stream stream} list which uses the given delivery method - */ - @NonNull - public static List getStreamsOfSpecifiedDelivery( - @Nullable final List streamList, - final DeliveryMethod deliveryMethod) { - return getFilteredStreamList(streamList, - stream -> stream.getDeliveryMethod() == deliveryMethod); - } - - /** - * Return a {@link Stream} list which only contains URL streams and non-torrent streams. - * - * @param streamList the original stream list - * @param the item type's class that extends {@link Stream} - * @return a stream list which only contains URL streams and non-torrent streams - */ - @NonNull - public static List getUrlAndNonTorrentStreams( - @Nullable final List streamList) { - return getFilteredStreamList(streamList, - stream -> stream.isUrl() && stream.getDeliveryMethod() != DeliveryMethod.TORRENT); - } - - /** - * Return a {@link Stream} list which only contains streams which can be played by the player. - * - *

- * Some formats are not supported, see {@link #SUPPORTED_ITAG_IDS} for more details. - * Torrent streams are also removed, because they cannot be retrieved, like OPUS streams using - * HLS as their delivery method, since they are not supported by ExoPlayer. - *

- * - * @param the item type's class that extends {@link Stream} - * @param streamList the original stream list - * @param serviceId the service ID from which the streams' list comes from - * @return a stream list which only contains streams that can be played the player - */ - @NonNull - public static List getPlayableStreams( - @Nullable final List streamList, final int serviceId) { - final int youtubeServiceId = YouTube.getServiceId(); - return getFilteredStreamList(streamList, - stream -> stream.getDeliveryMethod() != DeliveryMethod.TORRENT - && (stream.getDeliveryMethod() != DeliveryMethod.HLS - || stream.getFormat() != MediaFormat.OPUS) - && (serviceId != youtubeServiceId - || stream.getItagItem() == null - || SUPPORTED_ITAG_IDS.contains(stream.getItagItem().id))); - } - - /** - * Join the two lists of video streams (video_only and normal videos), - * and sort them according with default format chosen by the user. - * - * @param context the context to search for the format to give preference - * @param videoStreams the normal videos list - * @param videoOnlyStreams the video-only stream list - * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest - * @param preferVideoOnlyStreams if video-only streams should preferred when both video-only - * streams and normal video streams are available - * @return the sorted list - */ - @NonNull - public static List getSortedStreamVideosList( - @NonNull final Context context, - @Nullable final List videoStreams, - @Nullable final List videoOnlyStreams, - final boolean ascendingOrder, - final boolean preferVideoOnlyStreams) { - final SharedPreferences preferences = - PreferenceManager.getDefaultSharedPreferences(context); - - final boolean showHigherResolutions = preferences.getBoolean( - context.getString(R.string.show_higher_resolutions_key), false); - final MediaFormat defaultFormat = getDefaultFormat(context, - R.string.default_video_format_key, R.string.default_video_format_value); - - return getSortedStreamVideosList(defaultFormat, showHigherResolutions, videoStreams, - videoOnlyStreams, ascendingOrder, preferVideoOnlyStreams); - } - - /** - * Get a sorted list containing a set of default resolution info - * and additional resolution info if showHigherResolutions is true. - * - * @param resources the resources to get the resolutions from - * @param defaultResolutionKey the settings key of the default resolution - * @param additionalResolutionKey the settings key of the additional resolutions - * @param showHigherResolutions if higher resolutions should be included in the sorted list - * @return a sorted list containing the default and maybe additional resolutions - */ - public static List getSortedResolutionList( - final Resources resources, - final int defaultResolutionKey, - final int additionalResolutionKey, - final boolean showHigherResolutions) { - final List resolutions = new ArrayList<>(Arrays.asList( - resources.getStringArray(defaultResolutionKey))); - if (!showHigherResolutions) { - return resolutions; - } - final List additionalResolutions = Arrays.asList( - resources.getStringArray(additionalResolutionKey)); - // keep "best resolution" at the top - resolutions.addAll(1, additionalResolutions); - return resolutions; - } - - public static boolean isHighResolutionSelected(final String selectedResolution, - final int additionalResolutionKey, - final Resources resources) { - return Arrays.asList(resources.getStringArray( - additionalResolutionKey)) - .contains(selectedResolution); - } - - /** - * Filter the list of audio streams and return a list with the preferred stream for - * each audio track. Streams are sorted with the preferred language in the first position. - * - * @param context the context to search for the track to give preference - * @param audioStreams the list of audio streams - * @return the sorted, filtered list - */ - public static List getFilteredAudioStreams( - @NonNull final Context context, - @Nullable final List audioStreams) { - if (audioStreams == null) { - return Collections.emptyList(); - } - - final HashMap collectedStreams = new HashMap<>(); - - final Comparator cmp = getAudioFormatComparator(context); - - for (final AudioStream stream : audioStreams) { - if (stream.getDeliveryMethod() == DeliveryMethod.TORRENT - || (stream.getDeliveryMethod() == DeliveryMethod.HLS - && stream.getFormat() == MediaFormat.OPUS)) { - continue; - } - - final String trackId = Objects.toString(stream.getAudioTrackId(), ""); - - final AudioStream presentStream = collectedStreams.get(trackId); - if (presentStream == null || cmp.compare(stream, presentStream) > 0) { - collectedStreams.put(trackId, stream); - } - } - - // Filter unknown audio tracks if there are multiple tracks - if (collectedStreams.size() > 1) { - collectedStreams.remove(""); - } - - // Sort collected streams by name - return collectedStreams.values().stream().sorted(getAudioTrackNameComparator()) - .collect(Collectors.toList()); - } - - /** - * Group the list of audioStreams by their track ID and sort the resulting list by track name. - * - * @param context app context to get track names for sorting - * @param audioStreams list of audio streams - * @return list of audio streams lists representing individual tracks - */ - public static List> getGroupedAudioStreams( - @NonNull final Context context, - @Nullable final List audioStreams) { - if (audioStreams == null) { - return Collections.emptyList(); - } - - final HashMap> collectedStreams = new HashMap<>(); - - for (final AudioStream stream : audioStreams) { - final String trackId = Objects.toString(stream.getAudioTrackId(), ""); - if (collectedStreams.containsKey(trackId)) { - collectedStreams.get(trackId).add(stream); - } else { - final List list = new ArrayList<>(); - list.add(stream); - collectedStreams.put(trackId, list); - } - } - - // Filter unknown audio tracks if there are multiple tracks - if (collectedStreams.size() > 1) { - collectedStreams.remove(""); - } - - // Sort tracks alphabetically, sort track streams by quality - final Comparator nameCmp = getAudioTrackNameComparator(); - final Comparator formatCmp = getAudioFormatComparator(context); - - return collectedStreams.values().stream() - .sorted((o1, o2) -> nameCmp.compare(o1.get(0), o2.get(0))) - .map(streams -> streams.stream().sorted(formatCmp).collect(Collectors.toList())) - .collect(Collectors.toList()); - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Get a filtered stream list, by using Java 8 Stream's API and the given predicate. - * - * @param streamList the stream list to filter - * @param streamListPredicate the predicate which will be used to filter streams - * @param the item type's class that extends {@link Stream} - * @return a new stream list filtered using the given predicate - */ - private static List getFilteredStreamList( - @Nullable final List streamList, - final Predicate streamListPredicate) { - if (streamList == null) { - return Collections.emptyList(); - } - - return streamList.stream() - .filter(streamListPredicate) - .collect(Collectors.toList()); - } - - private static String computeDefaultResolution(@NonNull final Context context, final int key, - final int value) { - final SharedPreferences preferences = - PreferenceManager.getDefaultSharedPreferences(context); - - // Load the preferred resolution otherwise the best available - String resolution = preferences != null - ? preferences.getString(context.getString(key), context.getString(value)) - : context.getString(R.string.best_resolution_key); - - final String maxResolution = getResolutionLimit(context); - if (maxResolution != null - && (resolution.equals(context.getString(R.string.best_resolution_key)) - || compareVideoStreamResolution(maxResolution, resolution) < 1)) { - resolution = maxResolution; - } - return resolution; - } - - /** - * Return the index of the default stream in the list, that will be sorted in the process, based - * on the parameters defaultResolution and defaultFormat. - * - * @param defaultResolution the default resolution to look for - * @param bestResolutionKey key of the best resolution - * @param defaultFormat the default format to look for - * @param videoStreams a mutable list of the video streams to check (it will be sorted in - * place) - * @return index of the default resolution&format in the sorted videoStreams - */ - static int getDefaultResolutionIndex(final String defaultResolution, - final String bestResolutionKey, - final MediaFormat defaultFormat, - @Nullable final List videoStreams) { - if (videoStreams == null || videoStreams.isEmpty()) { - return -1; - } - - sortStreamList(videoStreams, false); - if (defaultResolution.equals(bestResolutionKey)) { - return 0; - } - - final int defaultStreamIndex = - getVideoStreamIndex(defaultResolution, defaultFormat, videoStreams); - - // this is actually an error, - // but maybe there is really no stream fitting to the default value. - if (defaultStreamIndex == -1) { - return 0; - } - return defaultStreamIndex; - } - - /** - * Join the two lists of video streams (video_only and normal videos), - * and sort them according with default format chosen by the user. - * - * @param defaultFormat format to give preference - * @param showHigherResolutions show >1080p resolutions - * @param videoStreams normal videos list - * @param videoOnlyStreams video only stream list - * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest - * @param preferVideoOnlyStreams if video-only streams should preferred when both video-only - * streams and normal video streams are available - * @return the sorted list - */ - @NonNull - static List getSortedStreamVideosList( - @Nullable final MediaFormat defaultFormat, - final boolean showHigherResolutions, - @Nullable final List videoStreams, - @Nullable final List videoOnlyStreams, - final boolean ascendingOrder, - final boolean preferVideoOnlyStreams - ) { - // Determine order of streams - // The last added list is preferred - final List> videoStreamsOrdered = - preferVideoOnlyStreams - ? Arrays.asList(videoStreams, videoOnlyStreams) - : Arrays.asList(videoOnlyStreams, videoStreams); - - final List allInitialStreams = videoStreamsOrdered.stream() - // Ignore lists that are null - .filter(Objects::nonNull) - .flatMap(List::stream) - // Filter out higher resolutions (or not if high resolutions should always be shown) - .filter(stream -> showHigherResolutions - || !HIGH_RESOLUTION_LIST.contains(stream.getResolution() - // Replace any frame rate with nothing - .replaceAll("p\\d+$", "p"))) - .collect(Collectors.toList()); - - final HashMap hashMap = new HashMap<>(); - // Add all to the hashmap - for (final VideoStream videoStream : allInitialStreams) { - hashMap.put(videoStream.getResolution(), videoStream); - } - - // Override the values when the key == resolution, with the defaultFormat - for (final VideoStream videoStream : allInitialStreams) { - if (videoStream.getFormat() == defaultFormat) { - hashMap.put(videoStream.getResolution(), videoStream); - } - } - - // Return the sorted list - return sortStreamList(new ArrayList<>(hashMap.values()), ascendingOrder); - } - - /** - * Sort the streams list depending on the parameter ascendingOrder; - *

- * It works like that:
- * - Take a string resolution, remove the letters, replace "0p60" (for 60fps videos) with "1" - * and sort by the greatest:
- *

-     *      720p     ->  720
-     *      720p60   ->  721
-     *      360p     ->  360
-     *      1080p    ->  1080
-     *      1080p60  ->  1081
-     * 
- * ascendingOrder ? 360 < 720 < 721 < 1080 < 1081 - * !ascendingOrder ? 1081 < 1080 < 721 < 720 < 360
- * - * @param videoStreams list that the sorting will be applied - * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest - * @return The sorted list (same reference as parameter videoStreams) - */ - private static List sortStreamList(final List videoStreams, - final boolean ascendingOrder) { - // Compares the quality of two video streams. - final Comparator comparator = Comparator.nullsLast(Comparator - .comparing(VideoStream::getResolution, ListHelper::compareVideoStreamResolution) - .thenComparingInt(s -> VIDEO_FORMAT_QUALITY_RANKING.indexOf(s.getFormat()))); - Collections.sort(videoStreams, ascendingOrder ? comparator : comparator.reversed()); - return videoStreams; - } - - /** - * Get the audio-stream from the list with the highest rank, depending on the comparator. - * Format will be ignored if it yields no results. - * - * @param audioStreams List of audio streams - * @param comparator The comparator used for determining the max/best/highest ranked value - * @return Index of audio stream that produces the highest ranked result or -1 if not found - */ - static int getAudioIndexByHighestRank(@Nullable final List audioStreams, - final Comparator comparator) { - if (audioStreams == null || audioStreams.isEmpty()) { - return -1; - } - - final AudioStream highestRankedAudioStream = audioStreams.stream() - .max(comparator).orElse(null); - - return audioStreams.indexOf(highestRankedAudioStream); - } - - /** - * Locates a possible match for the given resolution and format in the provided list. - * - *

In this order:

- * - *
    - *
  1. Find a format and resolution match
  2. - *
  3. Find a format and resolution match and ignore the refresh
  4. - *
  5. Find a resolution match
  6. - *
  7. Find a resolution match and ignore the refresh
  8. - *
  9. Find a resolution just below the requested resolution and ignore the refresh
  10. - *
  11. Give up
  12. - *
- * - * @param targetResolution the resolution to look for - * @param targetFormat the format to look for - * @param videoStreams the available video streams - * @return the index of the preferred video stream - */ - static int getVideoStreamIndex(@NonNull final String targetResolution, - final MediaFormat targetFormat, - @NonNull final List videoStreams) { - int fullMatchIndex = -1; - int fullMatchNoRefreshIndex = -1; - int resMatchOnlyIndex = -1; - int resMatchOnlyNoRefreshIndex = -1; - int lowerResMatchNoRefreshIndex = -1; - final String targetResolutionNoRefresh = targetResolution.replaceAll("p\\d+$", "p"); - - for (int idx = 0; idx < videoStreams.size(); idx++) { - final MediaFormat format = targetFormat == null - ? null - : videoStreams.get(idx).getFormat(); - final String resolution = videoStreams.get(idx).getResolution(); - final String resolutionNoRefresh = resolution.replaceAll("p\\d+$", "p"); - - if (format == targetFormat && resolution.equals(targetResolution)) { - fullMatchIndex = idx; - } - - if (format == targetFormat && resolutionNoRefresh.equals(targetResolutionNoRefresh)) { - fullMatchNoRefreshIndex = idx; - } - - if (resMatchOnlyIndex == -1 && resolution.equals(targetResolution)) { - resMatchOnlyIndex = idx; - } - - if (resMatchOnlyNoRefreshIndex == -1 - && resolutionNoRefresh.equals(targetResolutionNoRefresh)) { - resMatchOnlyNoRefreshIndex = idx; - } - - if (lowerResMatchNoRefreshIndex == -1 && compareVideoStreamResolution( - resolutionNoRefresh, targetResolutionNoRefresh) < 0) { - lowerResMatchNoRefreshIndex = idx; - } - } - - if (fullMatchIndex != -1) { - return fullMatchIndex; - } - if (fullMatchNoRefreshIndex != -1) { - return fullMatchNoRefreshIndex; - } - if (resMatchOnlyIndex != -1) { - return resMatchOnlyIndex; - } - if (resMatchOnlyNoRefreshIndex != -1) { - return resMatchOnlyNoRefreshIndex; - } - return lowerResMatchNoRefreshIndex; - } - - /** - * Fetches the desired resolution or returns the default if it is not found. - * The resolution will be reduced if video chocking is active. - * - * @param context Android app context - * @param defaultResolution the default resolution - * @param videoStreams the list of video streams to check - * @return the index of the preferred video stream - */ - private static int getDefaultResolutionWithDefaultFormat(@NonNull final Context context, - final String defaultResolution, - final List videoStreams) { - final MediaFormat defaultFormat = getDefaultFormat(context, - R.string.default_video_format_key, R.string.default_video_format_value); - return getDefaultResolutionIndex(defaultResolution, - context.getString(R.string.best_resolution_key), defaultFormat, videoStreams); - } - - @Nullable - private static MediaFormat getDefaultFormat(@NonNull final Context context, - @StringRes final int defaultFormatKey, - @StringRes final int defaultFormatValueKey) { - final SharedPreferences preferences = - PreferenceManager.getDefaultSharedPreferences(context); - - final String defaultFormat = context.getString(defaultFormatValueKey); - final String defaultFormatString = preferences.getString( - context.getString(defaultFormatKey), - defaultFormat - ); - - return getMediaFormatFromKey(context, defaultFormatString); - } - - @Nullable - private static MediaFormat getMediaFormatFromKey(@NonNull final Context context, - @NonNull final String formatKey) { - MediaFormat format = null; - if (formatKey.equals(context.getString(R.string.video_webm_key))) { - format = MediaFormat.WEBM; - } else if (formatKey.equals(context.getString(R.string.video_mp4_key))) { - format = MediaFormat.MPEG_4; - } else if (formatKey.equals(context.getString(R.string.video_3gp_key))) { - format = MediaFormat.v3GPP; - } else if (formatKey.equals(context.getString(R.string.audio_webm_key))) { - format = MediaFormat.WEBMA; - } else if (formatKey.equals(context.getString(R.string.audio_m4a_key))) { - format = MediaFormat.M4A; - } - return format; - } - - private static int compareVideoStreamResolution(@NonNull final String r1, - @NonNull final String r2) { - try { - final int res1 = Integer.parseInt(r1.replaceAll("0p\\d+$", "1") - .replaceAll("[^\\d.]", "")); - final int res2 = Integer.parseInt(r2.replaceAll("0p\\d+$", "1") - .replaceAll("[^\\d.]", "")); - return res1 - res2; - } catch (final NumberFormatException e) { - // Consider the first one greater because we don't know if the two streams are - // different or not (a NumberFormatException was thrown so we don't know the resolution - // of one stream or of all streams) - return 1; - } - } - - static boolean isLimitingDataUsage(@NonNull final Context context) { - return getResolutionLimit(context) != null; - } - - /** - * The maximum resolution allowed. - * - * @param context App context - * @return maximum resolution allowed or null if there is no maximum - */ - private static String getResolutionLimit(@NonNull final Context context) { - String resolutionLimit = null; - if (isMeteredNetwork(context)) { - final SharedPreferences preferences = - PreferenceManager.getDefaultSharedPreferences(context); - final String defValue = context.getString(R.string.limit_data_usage_none_key); - final String value = preferences.getString( - context.getString(R.string.limit_mobile_data_usage_key), defValue); - resolutionLimit = defValue.equals(value) ? null : value; - } - return resolutionLimit; - } - - /** - * The current network is metered (like mobile data)? - * - * @param context App context - * @return {@code true} if connected to a metered network - */ - public static boolean isMeteredNetwork(@NonNull final Context context) { - final ConnectivityManager manager = - ContextCompat.getSystemService(context, ConnectivityManager.class); - if (manager == null || manager.getActiveNetworkInfo() == null) { - return false; - } - - return manager.isActiveNetworkMetered(); - } - - /** - * Get a {@link Comparator} to compare {@link AudioStream}s by their format and bitrate. - * - *

The preferred stream will be ordered last.

- * - * @param context app context - * @return Comparator - */ - private static Comparator getAudioFormatComparator( - final @NonNull Context context) { - final MediaFormat defaultFormat = getDefaultFormat(context, - R.string.default_audio_format_key, R.string.default_audio_format_value); - return getAudioFormatComparator(defaultFormat, isLimitingDataUsage(context)); - } - - /** - * Get a {@link Comparator} to compare {@link AudioStream}s by their format and bitrate. - * - *

The preferred stream will be ordered last.

- * - * @param defaultFormat the default format to look for - * @param limitDataUsage choose low bitrate audio stream - * @return Comparator - */ - static Comparator getAudioFormatComparator( - @Nullable final MediaFormat defaultFormat, final boolean limitDataUsage) { - final List formatRanking = limitDataUsage - ? AUDIO_FORMAT_EFFICIENCY_RANKING : AUDIO_FORMAT_QUALITY_RANKING; - - Comparator bitrateComparator = - Comparator.comparingInt(AudioStream::getAverageBitrate); - if (limitDataUsage) { - bitrateComparator = bitrateComparator.reversed(); - } - - return Comparator.comparing(AudioStream::getFormat, (o1, o2) -> { - if (defaultFormat != null) { - return Boolean.compare(o1 == defaultFormat, o2 == defaultFormat); - } - return 0; - }).thenComparing(bitrateComparator).thenComparingInt( - stream -> formatRanking.indexOf(stream.getFormat())); - } - - /** - * Get a {@link Comparator} to compare {@link AudioStream}s by their tracks. - * - *

Tracks will be compared this order:

- *
    - *
  1. If {@code preferOriginalAudio}: use original audio
  2. - *
  3. Language matches {@code preferredLanguage}
  4. - *
  5. - * Track type ranks highest in this order: - * Original > Dubbed > Descriptive - *

    If {@code preferDescriptiveAudio}: - * Descriptive > Dubbed > Original

    - *
  6. - *
  7. Language is English
  8. - *
- * - *

The preferred track will be ordered last.

- * - * @param context App context - * @return Comparator - */ - private static Comparator getAudioTrackComparator( - @NonNull final Context context) { - final SharedPreferences preferences = - PreferenceManager.getDefaultSharedPreferences(context); - final Locale preferredLanguage = Localization.getPreferredLocale(context); - final boolean preferOriginalAudio = - preferences.getBoolean(context.getString(R.string.prefer_original_audio_key), - true); - final boolean preferDescriptiveAudio = - preferences.getBoolean(context.getString(R.string.prefer_descriptive_audio_key), - false); - - return getAudioTrackComparator(preferredLanguage, preferOriginalAudio, - preferDescriptiveAudio); - } - - /** - * Get a {@link Comparator} to compare {@link AudioStream}s by their tracks. - * - *

Tracks will be compared this order:

- *
    - *
  1. If {@code preferOriginalAudio}: use original audio
  2. - *
  3. Language matches {@code preferredLanguage}
  4. - *
  5. - * Track type ranks highest in this order: - * Original > Dubbed > Descriptive - *

    If {@code preferDescriptiveAudio}: - * Descriptive > Dubbed > Original

    - *
  6. - *
  7. Language is English
  8. - *
- * - *

The preferred track will be ordered last.

- * - * @param preferredLanguage Preferred audio stream language - * @param preferOriginalAudio Get the original audio track regardless of its language - * @param preferDescriptiveAudio Prefer the descriptive audio track if available - * @return Comparator - */ - static Comparator getAudioTrackComparator( - final Locale preferredLanguage, - final boolean preferOriginalAudio, - final boolean preferDescriptiveAudio) { - final String langCode = preferredLanguage.getISO3Language(); - final List trackTypeRanking = preferDescriptiveAudio - ? AUDIO_TRACK_TYPE_RANKING_DESCRIPTIVE : AUDIO_TRACK_TYPE_RANKING; - - return Comparator.comparing(AudioStream::getAudioTrackType, (o1, o2) -> { - if (preferOriginalAudio) { - return Boolean.compare( - o1 == AudioTrackType.ORIGINAL, o2 == AudioTrackType.ORIGINAL); - } - return 0; - }).thenComparing(AudioStream::getAudioLocale, - Comparator.nullsFirst(Comparator.comparing( - locale -> locale.getISO3Language().equals(langCode)))) - .thenComparing(AudioStream::getAudioTrackType, - Comparator.nullsFirst(Comparator.comparingInt(trackTypeRanking::indexOf))) - .thenComparing(AudioStream::getAudioLocale, - Comparator.nullsFirst(Comparator.comparing( - locale -> locale.getISO3Language().equals( - Locale.ENGLISH.getISO3Language())))); - } - - /** - * Get a {@link Comparator} to compare {@link AudioStream}s by their languages and track types - * for alphabetical sorting. - * - * @return Comparator - */ - private static Comparator getAudioTrackNameComparator() { - final Locale appLoc = Localization.getAppLocale(); - - return Comparator.comparing(AudioStream::getAudioLocale, Comparator.nullsLast( - Comparator.comparing(locale -> locale.getDisplayName(appLoc)))) - .thenComparing(AudioStream::getAudioTrackType, Comparator.nullsLast( - Comparator.naturalOrder())); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ListHelper.kt b/app/src/main/java/org/schabi/newpipe/util/ListHelper.kt new file mode 100644 index 00000000000..29e094615b8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/ListHelper.kt @@ -0,0 +1,1125 @@ +package org.schabi.newpipe.util + +import android.content.Context +import android.content.res.Resources +import android.net.ConnectivityManager +import android.util.Log +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.preference.PreferenceManager +import java.util.Locale +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.MediaFormat +import org.schabi.newpipe.extractor.ServiceList +import org.schabi.newpipe.extractor.stream.AudioStream +import org.schabi.newpipe.extractor.stream.AudioTrackType +import org.schabi.newpipe.extractor.stream.DeliveryMethod +import org.schabi.newpipe.extractor.stream.Stream +import org.schabi.newpipe.extractor.stream.VideoStream + +object ListHelper { + private const val TAG = "ListHelper" + + // Video format in order of quality. 0=lowest quality, n=highest quality + private val VIDEO_FORMAT_QUALITY_RANKING = + listOf(MediaFormat.v3GPP, MediaFormat.WEBM, MediaFormat.MPEG_4) + + // Audio format in order of quality. 0=lowest quality, n=highest quality + private val AUDIO_FORMAT_QUALITY_RANKING = + listOf(MediaFormat.MP3, MediaFormat.WEBMA, MediaFormat.M4A) + + // Audio format in order of efficiency. 0=least efficient, n=most efficient + private val AUDIO_FORMAT_EFFICIENCY_RANKING = + listOf(MediaFormat.MP3, MediaFormat.M4A, MediaFormat.WEBMA) + + // Use a Set for better performance + private val HIGH_RESOLUTION_LIST = setOf(1440, 2160) + + // Audio track types in order of priority. 0=lowest, n=highest + private val AUDIO_TRACK_TYPE_RANKING = + listOf( + AudioTrackType.DESCRIPTIVE, + AudioTrackType.SECONDARY, + AudioTrackType.DUBBED, + AudioTrackType.ORIGINAL + ) + + // Audio track types in order of priority when descriptive audio is preferred. + private val AUDIO_TRACK_TYPE_RANKING_DESCRIPTIVE = + listOf( + AudioTrackType.SECONDARY, + AudioTrackType.DUBBED, + AudioTrackType.ORIGINAL, + AudioTrackType.DESCRIPTIVE + ) + + /** + * List of supported YouTube Itag ids. + * The original order is kept. + * @see [org.schabi.newpipe.extractor.services.youtube.ItagItem.ITAG_LIST] + */ + private val SUPPORTED_ITAG_IDS = + listOf( + 17, 36, // video v3GPP + 18, 34, 35, 59, 78, 22, 37, 38, // video MPEG4 + 43, 44, 45, 46, // video webm + 171, 172, 139, 140, 141, 249, 250, 251, // audio + 160, 133, 134, 135, 212, 136, 298, 137, 299, 266, // video only + 278, 242, 243, 244, 245, 246, 247, 248, 271, 272, 302, 303, 308, 313, 315 + ) + + private val QUALITY_REGEX = Regex("""^(\d+)p(\d+)?(?:@(\d+)([km])?)?$""", RegexOption.IGNORE_CASE) + + /** + * @param context Android app context + * @param videoStreams list of the video streams to check + * @return index of the video stream with the default index + * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) + */ + @JvmStatic + fun getDefaultResolutionIndex( + context: Context, + videoStreams: MutableList + ): Int { + val defaultResolution = computeDefaultResolution( + context, + R.string.default_resolution_key, + R.string.default_resolution_value + ) + return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams) + } + + /** + * @param context Android app context + * @param videoStreams list of the video streams to check + * @param defaultResolution the default resolution to look for + * @return index of the video stream with the default index + * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) + */ + @JvmStatic + fun getResolutionIndex( + context: Context, + videoStreams: MutableList, + defaultResolution: String + ): Int { + return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams) + } + + /** + * @param context Android app context + * @param videoStreams list of the video streams to check + * @return index of the video stream with the default index + * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) + */ + @JvmStatic + fun getPopupDefaultResolutionIndex( + context: Context, + videoStreams: MutableList + ): Int { + val defaultResolution = computeDefaultResolution( + context, + R.string.default_popup_resolution_key, + R.string.default_popup_resolution_value + ) + return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams) + } + + /** + * @param context Android app context + * @param videoStreams list of the video streams to check + * @param defaultResolution the default resolution to look for + * @return index of the video stream with the default index + * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) + */ + @JvmStatic + fun getPopupResolutionIndex( + context: Context, + videoStreams: MutableList, + defaultResolution: String + ): Int { + return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams) + } + + @JvmStatic + fun getDefaultAudioFormat( + context: Context, + audioStreams: List + ): Int { + return getAudioIndexByHighestRank( + audioStreams, + getAudioTrackComparator(context).thenComparing(getAudioFormatComparator(context)) + ) + } + + @JvmStatic + fun getDefaultAudioTrackGroup( + context: Context, + groupedAudioStreams: List>? + ): Int { + if (groupedAudioStreams.isNullOrEmpty()) { + return -1 + } + val cmp = getAudioTrackComparator(context) + val highestRanked = + groupedAudioStreams.maxWithOrNull(Comparator { o1, o2 -> cmp.compare(o1[0], o2[0]) }) + return groupedAudioStreams.indexOf(highestRanked) + } + + @JvmStatic + fun getAudioFormatIndex( + context: Context, + audioStreams: List, + trackId: String? + ): Int { + if (trackId != null) { + for (i in audioStreams.indices) { + val s = audioStreams[i] + if (s.audioTrackId != null && + s.audioTrackId == trackId + ) { + return i + } + } + } + return getDefaultAudioFormat(context, audioStreams) + } + + /** + * Return a [Stream] list which uses the given delivery method from a [Stream] list. + * + * @param streamList the original [Stream] list + * @param deliveryMethod the [DeliveryMethod] delivery method + * @param the item type's class that extends [Stream] + * @return a [Stream] list which uses the given delivery method + */ + @JvmStatic + fun getStreamsOfSpecifiedDelivery( + streamList: List?, + deliveryMethod: DeliveryMethod + ): List { + return getFilteredStreamList(streamList) { stream -> + stream.deliveryMethod == deliveryMethod + } + } + + /** + * Return a [Stream] list which only contains URL streams and non-torrent streams. + * + * @param streamList the original stream list + * @param the item type's class that extends [Stream] + * @return a stream list which only contains URL streams and non-torrent streams + */ + @JvmStatic + fun getUrlAndNonTorrentStreams( + streamList: List? + ): List { + return getFilteredStreamList(streamList) { stream -> + stream.isUrl && stream.deliveryMethod != DeliveryMethod.TORRENT + } + } + + /** + * Return a [Stream] list which only contains streams which can be played by the player. + * + * Some formats are not supported, see [ListHelper.SUPPORTED_ITAG_IDS] for more details. + * Torrent streams are also removed, because they cannot be retrieved, like OPUS streams using + * HLS as their delivery method, since they are not supported by ExoPlayer. + * + * @param the item type's class that extends [Stream] + * @param streamList the original stream list + * @param serviceId the service ID from which the streams' list comes from + * @return a stream list which only contains streams that can be played the player + */ + @JvmStatic + fun getPlayableStreams( + streamList: List?, + serviceId: Int + ): List { + val youtubeServiceId = ServiceList.YouTube.serviceId + return getFilteredStreamList(streamList) { stream -> + stream.deliveryMethod != DeliveryMethod.TORRENT && + ( + stream.deliveryMethod != DeliveryMethod.HLS || + stream.format != MediaFormat.OPUS + ) && + ( + serviceId != youtubeServiceId || + stream.itagItem?.id?.let { SUPPORTED_ITAG_IDS.contains(it) } != false + ) + } + } + + /** + * Join the two lists of video streams (video_only and normal videos), + * and sort them according to the default format chosen by the user. + * + * @param context the context to search for the format to give preference + * @param videoStreams the normal videos list + * @param videoOnlyStreams the video-only stream list + * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest + * @param preferVideoOnlyStreams if video-only streams should be preferred when both video-only + * streams and normal video streams are available + * @return the sorted list + */ + @JvmStatic + fun getSortedStreamVideosList( + context: Context, + videoStreams: List?, + videoOnlyStreams: List?, + ascendingOrder: Boolean, + preferVideoOnlyStreams: Boolean + ): MutableList { + val preferences = + PreferenceManager.getDefaultSharedPreferences(context) + val showHigherResolutions = preferences.getBoolean( + context.getString(R.string.show_higher_resolutions_key), + false + ) + val defaultFormat = getDefaultFormat( + context, + R.string.default_video_format_key, + R.string.default_video_format_value + ) + return getSortedStreamVideosList( + defaultFormat, + showHigherResolutions, + videoStreams, + videoOnlyStreams, + ascendingOrder, + preferVideoOnlyStreams + ) + } + + /** + * Get a sorted list containing a set of default resolution info + * and additional resolution info if showHigherResolutions is true. + * + * @param resources the resources to get the resolutions from + * @param defaultResolutionKey the settings key of the default resolution + * @param additionalResolutionKey the settings key of the additional resolutions + * @param showHigherResolutions if higher resolutions should be included in the sorted list + * @return a sorted list containing the default and maybe additional resolutions + */ + @JvmStatic + fun getSortedResolutionList( + resources: Resources, + defaultResolutionKey: Int, + additionalResolutionKey: Int, + showHigherResolutions: Boolean + ): List { + val resolutions = + resources.getStringArray(defaultResolutionKey).toMutableList() + if (!showHigherResolutions) { + return resolutions + } + val additionalResolutions = + resources.getStringArray(additionalResolutionKey).toList() + // keep "best resolution" at the top + resolutions.addAll(1, additionalResolutions) + return resolutions + } + + @JvmStatic + fun isHighResolutionSelected( + selectedResolution: String, + additionalResolutionKey: Int, + resources: Resources + ): Boolean { + return resources.getStringArray( + additionalResolutionKey + ) + .contains(selectedResolution) + } + + /** + * Filter the list of audio streams and return a list with the preferred stream for + * each audio track. Streams are sorted with the preferred language in the first position. + * + * @param context the context to search for the track to give preference + * @param audioStreams the list of audio streams + * @return the sorted, filtered list + */ + @JvmStatic + fun getFilteredAudioStreams( + context: Context, + audioStreams: List? + ): List { + if (audioStreams == null) { + return emptyList() + } + val collectedStreams = mutableMapOf() + val cmp = getAudioFormatComparator(context) + for (stream in audioStreams) { + if (stream.deliveryMethod == DeliveryMethod.TORRENT || + ( + stream.deliveryMethod == DeliveryMethod.HLS && + stream.format == MediaFormat.OPUS + ) + ) { + continue + } + val trackId = stream.audioTrackId ?: "" + val presentStream = collectedStreams[trackId] + if (presentStream == null || cmp.compare(stream, presentStream) > 0) { + collectedStreams[trackId] = stream + } + } + // Filter unknown audio tracks if there are multiple tracks + if (collectedStreams.size > 1) { + collectedStreams.remove("") + } + // Sort collected streams by name + return collectedStreams.values.sortedWith(getAudioTrackNameComparator()) + } + + /** + * Group the list of audioStreams by their track ID and sort the resulting list by track name. + * + * @param context app context to get track names for sorting + * @param audioStreams list of audio streams + * @return list of audio streams lists representing individual tracks + */ + @JvmStatic + fun getGroupedAudioStreams( + context: Context, + audioStreams: List? + ): List> { + if (audioStreams == null) { + return emptyList() + } + val collectedStreams = audioStreams + .groupBy { it.audioTrackId ?: "" } + .toMutableMap() + // Filter unknown audio tracks if there are multiple tracks + if (collectedStreams.size > 1) { + collectedStreams.remove("") + } + // Sort tracks alphabetically, sort track streams by quality + val nameCmp = getAudioTrackNameComparator() + val formatCmp = getAudioFormatComparator(context) + return collectedStreams.values + .sortedWith(Comparator { o1, o2 -> nameCmp.compare(o1[0], o2[0]) }) + .map { streams -> streams.sortedWith(formatCmp) } + } + + // //////////////////////////////////////////////////////////////////////// + // Utils + // //////////////////////////////////////////////////////////////////////// + + /** + * Get a filtered stream list using the given predicate. + * + * @param streamList the stream list to filter + * @param predicate the predicate which will be used to filter streams + * @param the item type's class that extends [Stream] + * @return a new stream list filtered using the given predicate + */ + private fun getFilteredStreamList( + streamList: List?, + predicate: (S) -> Boolean + ): List { + if (streamList == null) { + return emptyList() + } + return streamList.filter(predicate) + } + + private fun computeDefaultResolution( + context: Context, + key: Int, + value: Int + ): String { + val preferences = + PreferenceManager.getDefaultSharedPreferences(context) + val bestResolutionKey = context.getString(R.string.best_resolution_key) + + // Load the preferred resolution otherwise the best available + var resolution = preferences?.getString(context.getString(key), context.getString(value)) + ?: bestResolutionKey + + val maxResolution = getResolutionLimit(context) + if (maxResolution != null && + ( + resolution == bestResolutionKey || + compareVideoStreamResolution(maxResolution, resolution) < 1 + ) + ) { + resolution = maxResolution + } + return resolution + } + + /** + * Return the index of the default stream in the list, that will be sorted in the process, based + * on the parameters defaultResolution and defaultFormat. + * + * The method performs the following steps: + * + * 1. Validate that streams exist + * 2. Sort the streams by quality + * 3. Handle the special case where the user requested the "best" resolution + * 4. Use the matching algorithm to find the best candidate + * 5. Fallback to index 0 if no match was found + * + * @param defaultResolution the default resolution to look for + * @param bestResolutionKey key of the best resolution + * @param defaultFormat the default format to look for + * @param videoStreams a mutable list of the video streams to check (it will be sorted in + * place) + * @return index of the default resolution&format in the sorted videoStreams + */ + @JvmStatic + fun getDefaultResolutionIndex( + defaultResolution: String, + bestResolutionKey: String, + defaultFormat: MediaFormat?, + videoStreams: MutableList? + ): Int { + if (videoStreams.isNullOrEmpty()) return -1 + + val streamsWithParsedQuality = videoStreams.wrapWithQuality().toMutableList() + + // Ensure streams are sorted by quality before selecting one. + sortStreamList(streamsWithParsedQuality, false) + videoStreams.clear() + videoStreams.addAll(streamsWithParsedQuality.map { it.stream }) + + // If the user explicitly requested the "best" resolution, + // simply return the first stream since the list is already sorted. + if (defaultResolution == bestResolutionKey) return 0 + + // Find the index of the best matching stream + val defaultStreamIndex = internalGetVideoStreamIndex( + defaultResolution, + defaultFormat, + streamsWithParsedQuality + ) + + // If no suitable match was found, fall back to the first stream + // (which is the best available due to sorting). + return if (defaultStreamIndex == -1) 0 else defaultStreamIndex + } + + /** + * Join the two lists of video streams (video_only and normal videos), + * and sort them according to the default format chosen by the user. + * + * @param defaultFormat format to give preference + * @param showHigherResolutions show >1080p resolutions + * @param videoStreams normal videos list + * @param videoOnlyStreams video only stream list + * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest + * @param preferVideoOnlyStreams if video-only streams should be preferred when both video-only + * streams and normal video streams are available + * @return the sorted list + */ + @JvmStatic + fun getSortedStreamVideosList( + defaultFormat: MediaFormat?, + showHigherResolutions: Boolean, + videoStreams: List?, + videoOnlyStreams: List?, + ascendingOrder: Boolean, + preferVideoOnlyStreams: Boolean + ): MutableList { + // Determine order of streams + // The last added list is preferred + val videoStreamListsInPreferredOrder = if (preferVideoOnlyStreams) { + mutableListOf(videoStreams, videoOnlyStreams) + } else { + mutableListOf(videoOnlyStreams, videoStreams) + } + + val allInitialStreams = videoStreamListsInPreferredOrder + // Ignore lists that are null + .filterNotNull() + .flatten() + .wrapWithQuality() + // Filter out higher resolutions (or not if high resolutions should always be shown) + .filter { stream -> + showHigherResolutions || !HIGH_RESOLUTION_LIST.contains( + stream.quality.resolution + ) + } + .toMutableList() + + val streamsWithDefaultFormatPreferred = mutableMapOf() + + // add all streams based on key [ListHelper.qualityKeyOf] to [streamsWithDefaultFormatPreferred] + allInitialStreams + .forEach { streamsWithDefaultFormatPreferred[qualityKeyOf(it)] = it } + + // Ensure that streams with 'defaultFormat' are included in streamMap as they are + // preferred. They might have been overridden if allInitialStreams has more than one stream + // for the same resolution key but a none 'defaultFormat' stream was added later. + // See 'qualityKeyOf'. + defaultFormat?.let { fmt -> + allInitialStreams.filter { it.stream.format == fmt } + .forEach { streamsWithDefaultFormatPreferred[qualityKeyOf(it)] = it } + } + + return sortStreamList( + streamsWithDefaultFormatPreferred.values.toMutableList(), + ascendingOrder + ) + .map { it.stream } + .toMutableList() + } + + /** + * Here we create a key based on resolution, frame rate and bitrate + */ + private fun qualityKeyOf(item: VideoStreamWithQuality) = "${item.quality.resolution}p${item.quality.fps}@${item.quality.bitrate}" + + /** + * Sort the streams list depending on the parameter ascendingOrder; + * + * It uses [ListHelper.getVideoStreamQualityComparator] and produces results like this: + * ``` + * ascendingOrder ? 360p < 720p < 720p60 < 1080p < 1080p@60 + * !ascendingOrder ? 1080p60 < 1080p < 720p60 < 720p < 360p + * ``` + * + * @param videoStreams list that the sorting will be applied + * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest + * @return The sorted list (same reference as parameter videoStreams) + */ + private fun sortStreamList( + videoStreams: MutableList, + ascendingOrder: Boolean + ): MutableList { + val comparator = getVideoStreamQualityComparator() + videoStreams.sortWith(if (ascendingOrder) comparator else comparator.reversed()) + return videoStreams + } + + private fun getVideoStreamQualityComparator() = compareBy + { it.quality.resolution } + .thenBy { it.quality.fps } + .thenBy { it.quality.formatRank } + .thenBy { it.quality.bitrate } + + /** + * Get the audio-stream from the list with the highest rank, depending on the comparator. + * Format will be ignored if it yields no results. + * + * @param audioStreams List of audio streams + * @param comparator The comparator used for determining the max/best/highest ranked value + * @return Index of audio stream that produces the highest ranked result or -1 if not found + */ + @JvmStatic + fun getAudioIndexByHighestRank( + audioStreams: List?, + comparator: Comparator + ): Int { + if (audioStreams.isNullOrEmpty()) { + return -1 + } + val highestRankedAudioStream = audioStreams.maxWithOrNull(comparator) + return audioStreams.indexOf(highestRankedAudioStream) + } + + /** + * Locate the best matching video stream for a requested resolution and format in the provided list. + * + * The algorithm iterates over all available streams and assigns each one + * a "priority class". Lower numbers represent a better match. + * + * Matching priority (best -> worst): + * + * 1. Format + resolution + fps + exact bitrate + * 2. Format + resolution + fps + * 3. Format + resolution + * 4. Resolution + fps + the closest bitrate + * 5. Resolution + fps + * 6. Resolution + * 7. Next lower resolution + * 8. Give up + * + * If multiple streams fall into the same priority class, + * the stream with the closest bitrate to the requested one is preferred. + * + * The list of streams is expected to already be sorted by [ListHelper.sortStreamList] + * + * @param targetResolution the resolution to look for. E.g.: "720p", "720p60", "720p60@123k", or "720p@2m" + * @param targetFormat the format to look for + * @param videoStreams the available video streams + * @return the index of the preferred video stream + */ + @JvmStatic + fun getVideoStreamIndex( + targetResolution: String, + targetFormat: MediaFormat?, + videoStreams: List + ): Int { + return internalGetVideoStreamIndex( + targetResolution, + targetFormat, + videoStreams.wrapWithQuality() + ) + } + + private fun internalGetVideoStreamIndex( + targetResolution: String, + targetFormat: MediaFormat?, + videoStreams: List + ): Int { + val target = parseQuality(targetResolution) + + /** + * Internal helper representing a candidate stream. + * + * index -> index in the original stream list + * priority -> matching class (lower = better) + * bitrateDiff -> distance to the requested bitrate + */ + data class Candidate( + val index: Int, + val priority: Int, + val bitrateDiff: Long + ) + + val candidateComparator = + compareBy { it.priority } + .thenBy { it.bitrateDiff } + // use lower bitrate to save bandwidth + .thenBy { videoStreams[it.index].quality.bitrate } + + var best: Candidate? = null + + for ((index, item) in videoStreams.withIndex()) { + val (stream, quality) = item + + // Check individual match criteria + val isFormatMatch = targetFormat != null && stream.format == targetFormat + val isResMatch = quality.resolution == target.resolution + val isFpsMatch = quality.fps == target.fps + + // Compute bitrate difference only if both bitrates exist + val bitrateDiff = if (target.bitrate > 0 && quality.bitrate > 0) { + kotlin.math.abs(quality.bitrate - target.bitrate) + } else { + Long.MAX_VALUE + } + + /** + * Determine the matching priority of this stream. + * + * The "when" block classifies the stream into a priority group. + * Lower numbers mean better matches. + */ + val priority = when { + // Perfect match + isFormatMatch && isResMatch && isFpsMatch && bitrateDiff == 0L -> 1 + + isFormatMatch && isResMatch && isFpsMatch -> 2 + + isFormatMatch && isResMatch -> 3 + + isResMatch && isFpsMatch -> 4 + + isResMatch -> 5 + + // Accept lower resolutions as fallback + quality.resolution < target.resolution -> 6 + + // If none of the matching conditions apply, + // this stream is not considered a valid candidate. + else -> continue + } + + val candidate = Candidate(index, priority, bitrateDiff) + + if (best == null || candidateComparator.compare(candidate, best) < 0) { + best = candidate + if (best.priority == 1) { + // perfect match, stop searching + break + } + } + } + + // Return the index of the best matching stream, or -1 if none was found + return best?.index ?: -1 + } + + /** + * Fetches the desired resolution or returns the default if it is not found. + * The resolution will be reduced if video chocking is active. + * + * @param context Android app context + * @param defaultResolution the default resolution + * @param videoStreams the list of video streams to check + * @return the index of the preferred video stream + */ + private fun getDefaultResolutionWithDefaultFormat( + context: Context, + defaultResolution: String, + videoStreams: MutableList + ): Int { + val defaultFormat = getDefaultFormat( + context, + R.string.default_video_format_key, + R.string.default_video_format_value + ) + return getDefaultResolutionIndex( + defaultResolution, + context.getString(R.string.best_resolution_key), + defaultFormat, + videoStreams + ) + } + + private fun getDefaultFormat( + context: Context, + @StringRes defaultFormatKey: Int, + @StringRes defaultFormatValueKey: Int + ): MediaFormat? { + val preferences = + PreferenceManager.getDefaultSharedPreferences(context) + val defaultFormat = context.getString(defaultFormatValueKey) + val defaultFormatString = preferences.getString( + context.getString(defaultFormatKey), + defaultFormat + ) ?: defaultFormat + return getMediaFormatFromKey(context, defaultFormatString) + } + + private fun getMediaFormatFromKey( + context: Context, + formatKey: String + ): MediaFormat? { + return when (formatKey) { + context.getString(R.string.video_webm_key) -> MediaFormat.WEBM + context.getString(R.string.video_mp4_key) -> MediaFormat.MPEG_4 + context.getString(R.string.video_3gp_key) -> MediaFormat.v3GPP + context.getString(R.string.audio_webm_key) -> MediaFormat.WEBMA + context.getString(R.string.audio_m4a_key) -> MediaFormat.M4A + else -> null + } + } + + /** + * Compares two video stream resolution strings in descending order. + * + * Returns a negative value if r1 has higher quality than r2, zero if equal, + * and a positive value if r1 has lower quality than r2. + * Note: arguments are intentionally compared in reverse order (r2 vs r1) + * to produce descending comparison, matching the original Java behavior. + */ + private fun compareVideoStreamResolution( + r1: String, + r2: String + ): Int { + val r1Quality = parseQuality(r1) + val r2Quality = parseQuality(r2) + + val comparator = + compareBy + { it.resolution } + .thenBy { it.fps } + return comparator.compare(r2Quality, r1Quality) + } + + @JvmStatic + fun isLimitingDataUsage(context: Context): Boolean { + return getResolutionLimit(context) != null + } + + /** + * The maximum resolution allowed. + * + * @param context App context + * @return maximum resolution allowed or null if there is no maximum + */ + private fun getResolutionLimit(context: Context): String? { + if (!isMeteredNetwork(context)) { + return null + } + val preferences = + PreferenceManager.getDefaultSharedPreferences(context) + val defValue = context.getString(R.string.limit_data_usage_none_key) + val value = preferences.getString( + context.getString(R.string.limit_mobile_data_usage_key), + defValue + ) + return if (defValue == value) null else value + } + + /** + * The current network is metered (like mobile data)? + * + * @param context App context + * @return `true` if connected to a metered network + */ + @JvmStatic + fun isMeteredNetwork(context: Context): Boolean { + val manager = + ContextCompat.getSystemService(context, ConnectivityManager::class.java) + ?: return false + return manager.isActiveNetworkMetered + } + + /** + * Get a [Comparator] to compare [AudioStream]s by their format and bitrate. + * + * The preferred stream will be ordered last. + * + * @param context app context + * @return Comparator + */ + private fun getAudioFormatComparator( + context: Context + ): Comparator { + val defaultFormat = getDefaultFormat( + context, + R.string.default_audio_format_key, + R.string.default_audio_format_value + ) + return getAudioFormatComparator(defaultFormat, isLimitingDataUsage(context)) + } + + /** + * Get a [Comparator] to compare [AudioStream]s by their format and bitrate. + * + * The preferred stream will be ordered last. + * + * @param defaultFormat the default format to look for + * @param limitDataUsage choose low bitrate audio stream + * @return Comparator + */ + @JvmStatic + fun getAudioFormatComparator( + defaultFormat: MediaFormat?, + limitDataUsage: Boolean + ): Comparator { + val formatRanking = if (limitDataUsage) { + AUDIO_FORMAT_EFFICIENCY_RANKING + } else { + AUDIO_FORMAT_QUALITY_RANKING + } + var bitrateComparator = + Comparator.comparingInt { stream: AudioStream -> stream.averageBitrate } + if (limitDataUsage) { + bitrateComparator = bitrateComparator.reversed() + } + return Comparator.comparing( + { it.format }, + Comparator { o1: MediaFormat?, o2: MediaFormat? -> + if (defaultFormat != null) { + (o1 == defaultFormat).compareTo(o2 == defaultFormat) + } else { + 0 + } + } + ).thenComparing(bitrateComparator).thenComparingInt { stream -> + formatRanking.indexOf( + stream.format + ) + } + } + + /** + * Get a [Comparator] to compare [AudioStream]s by their tracks. + * + * Tracks will be compared this order: + * 1. If [preferOriginalAudio]: use original audio + * 2. Language matches [preferredLanguage] + * 3. Track type ranks highest in this order: + * *Original* > *Dubbed* > *Descriptive* + * If [preferDescriptiveAudio]: + * *Descriptive* > *Dubbed* > *Original* + * 4. Language is English + * + * The preferred track will be ordered last. + * + * @param context App context + * @return Comparator + */ + private fun getAudioTrackComparator( + context: Context + ): Comparator { + val preferences = + PreferenceManager.getDefaultSharedPreferences(context) + val preferredLanguage = Localization.getPreferredLocale(context) + val preferOriginalAudio = + preferences.getBoolean( + context.getString(R.string.prefer_original_audio_key), + true + ) + val preferDescriptiveAudio = + preferences.getBoolean( + context.getString(R.string.prefer_descriptive_audio_key), + false + ) + return getAudioTrackComparator( + preferredLanguage, + preferOriginalAudio, + preferDescriptiveAudio + ) + } + + /** + * Get a [Comparator] to compare [AudioStream]s by their tracks. + * + * Tracks will be compared this order: + * 1. If [preferOriginalAudio]: use original audio + * 2. Language matches [preferredLanguage] + * 3. Track type ranks highest in this order: + * *Original* > *Dubbed* > *Descriptive* + * If [preferDescriptiveAudio]: + * *Descriptive* > *Dubbed* > *Original* + * 4. Language is English + * + * The preferred track will be ordered last. + * + * @param preferredLanguage Preferred audio stream language + * @param preferOriginalAudio Get the original audio track regardless of its language + * @param preferDescriptiveAudio Prefer the descriptive audio track if available + * @return Comparator + */ + @JvmStatic + fun getAudioTrackComparator( + preferredLanguage: Locale, + preferOriginalAudio: Boolean, + preferDescriptiveAudio: Boolean + ): Comparator { + val langCode = preferredLanguage.isO3Language + val trackTypeRanking = if (preferDescriptiveAudio) { + AUDIO_TRACK_TYPE_RANKING_DESCRIPTIVE + } else { + AUDIO_TRACK_TYPE_RANKING + } + return Comparator.comparing( + { it.audioTrackType }, + Comparator { o1: AudioTrackType?, o2: AudioTrackType? -> + if (preferOriginalAudio) { + (o1 == AudioTrackType.ORIGINAL).compareTo(o2 == AudioTrackType.ORIGINAL) + } else { + 0 + } + } + ).thenComparing( + { it.audioLocale }, + Comparator.nullsFirst( + Comparator.comparing { locale: Locale? -> locale?.isO3Language == langCode } + ) + ) + .thenComparing( + { it.audioTrackType }, + Comparator.nullsFirst(Comparator.comparingInt { trackTypeRanking.indexOf(it) }) + ) + .thenComparing( + { it.audioLocale }, + Comparator.nullsFirst( + Comparator.comparing { locale: Locale? -> locale?.isO3Language == Locale.ENGLISH.isO3Language } + ) + ) + } + + /** + * Get a [Comparator] to compare [AudioStream]s by their languages and track types + * for alphabetical sorting. + * + * @return Comparator + */ + private fun getAudioTrackNameComparator(): Comparator { + val appLoc = Localization.getAppLocale() + return Comparator.comparing( + { it.audioLocale }, + Comparator.nullsLast(Comparator.comparing { locale: Locale? -> locale?.getDisplayName(appLoc) ?: "" }) + ) + .thenComparing( + { it.audioTrackType }, + Comparator.nullsLast( + Comparator.naturalOrder() + ) + ) + } + + // --------data classes---------- + + /** + * containing the stream and its parsed [VideoQuality] information + */ + data class VideoStreamWithQuality( + val stream: VideoStream, + val quality: VideoQuality + ) + + data class VideoQuality( + val resolution: Int, + val fps: Int, + val bitrate: Long, + val formatRank: Int + ) + + // -------- private helpers ------------ + + private fun VideoStream.toQuality(): VideoQuality { + return parseQuality(getResolution(), format) + } + + /** + * Maps each VideoStream to a [VideoStreamWithQuality] using its toQuality() function. + * This avoids repeatedly parsing the quality string during matching. + */ + private fun Iterable.wrapWithQuality(): List { + return map { stream -> VideoStreamWithQuality(stream, stream.toQuality()) } + } + + /** + * Parses a video quality string into a [VideoQuality] object. + * + * Supports strings like: `"720p"`, `"720p60"`, `"720p60@1500k"` or `"1080p@2m"`. + * The components represent resolution, optional fps, and optional bitrate. + * Bitrate units `k` and `m` are interpreted as x1000 and x1_000_000 respectively. + * + * @param resFpsBitrate string to parse for quality information (e.g. `"720p60@1500k"`), may be null + * @param format optional media format used to determine the format rank + * @return the parsed [VideoQuality] or a zero-quality fallback if parsing fails + * or [resFpsBitrate] was null + */ + private fun parseQuality( + resFpsBitrate: String?, + format: MediaFormat? = null + ): VideoQuality { + val resFpsBitrateStr = resFpsBitrate?.trim() ?: return VideoQuality(0, 0, 0L, -1) + + val match = QUALITY_REGEX.matchEntire(resFpsBitrateStr) + ?: run { + if (MainActivity.DEBUG) { + Log.d(TAG, "Cannot parse quality: \"$resFpsBitrateStr\"") + } + return VideoQuality(0, 0, 0L, -1) + } + + val resolution = match.groupValues[1].toInt() + val fps = match.groupValues[2].toIntOrNull() ?: 0 + + var bitrate = match.groupValues[3].toLongOrNull() ?: 0L + val bitrateUnit = match.groupValues[4].lowercase() + if (bitrate > 0 && bitrateUnit.isNotEmpty()) { + try { + bitrate = when (bitrateUnit) { + "k" -> Math.multiplyExact(bitrate, 1000L) + "m" -> Math.multiplyExact(bitrate, 1_000_000L) + else -> bitrate + } + } catch (e: ArithmeticException) { + if (MainActivity.DEBUG) { + Log.d(TAG, "Bitrate overflow in \"$resFpsBitrateStr\"", e) + } + bitrate = 0L + } + } + + return VideoQuality( + resolution, + fps, + bitrate, + format?.let { VIDEO_FORMAT_QUALITY_RANKING.indexOf(it) } ?: -1 + ) + } +} diff --git a/app/src/test/java/org/schabi/newpipe/util/ListHelperKtTest.kt b/app/src/test/java/org/schabi/newpipe/util/ListHelperKtTest.kt new file mode 100644 index 00000000000..030999b7a05 --- /dev/null +++ b/app/src/test/java/org/schabi/newpipe/util/ListHelperKtTest.kt @@ -0,0 +1,1078 @@ +package org.schabi.newpipe.util + +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.schabi.newpipe.extractor.MediaFormat +import org.schabi.newpipe.extractor.ServiceList +import org.schabi.newpipe.extractor.services.youtube.ItagItem +import org.schabi.newpipe.extractor.stream.AudioStream +import org.schabi.newpipe.extractor.stream.AudioTrackType +import org.schabi.newpipe.extractor.stream.DeliveryMethod +import org.schabi.newpipe.extractor.stream.VideoStream + +class ListHelperKtTest { + + private val audioStreamsList = listOf( + audioStream("m4a-128-1", MediaFormat.M4A, 128), + audioStream("webma-192", MediaFormat.WEBMA, 192), + audioStream("mp3-64", MediaFormat.MP3, 64), + audioStream("webma-192-2", MediaFormat.WEBMA, 192), + audioStream("m4a-128-2", MediaFormat.M4A, 128), + audioStream("mp3-128", MediaFormat.MP3, 128), + audioStream("webma-64", MediaFormat.WEBMA, 64), + audioStream("m4a-320", MediaFormat.M4A, 320), + audioStream("mp3-192", MediaFormat.MP3, 192), + audioStream("webma-320", MediaFormat.WEBMA, 320) + ) + + private val audioTracksList = listOf( + audioTrack("en.or", "en.or", Locale.ENGLISH, AudioTrackType.ORIGINAL), + audioTrack("en.du", "en.du", Locale.ENGLISH, AudioTrackType.DUBBED), + audioTrack("en.ds", "en.ds", Locale.ENGLISH, AudioTrackType.DESCRIPTIVE), + audioTrack("unknown", null, null, null), + audioTrack("de.du", "de.du", Locale.GERMAN, AudioTrackType.DUBBED), + audioTrack("de.ds", "de.ds", Locale.GERMAN, AudioTrackType.DESCRIPTIVE) + ) + + private val videoStreamsList = listOf( + videoStream("mpeg4-1080", MediaFormat.MPEG_4, "1080p"), + videoStream("mpeg4-720-60", MediaFormat.MPEG_4, "720p60"), + videoStream("mpeg4-720", MediaFormat.MPEG_4, "720p"), + videoStream("webm-480", MediaFormat.WEBM, "480p"), + videoStream("mpeg4-360", MediaFormat.MPEG_4, "360p"), + videoStream("webm-360", MediaFormat.WEBM, "360p"), + videoStream("v3gpp-240-60", MediaFormat.v3GPP, "240p60"), + videoStream("webm-144", MediaFormat.WEBM, "144p") + ) + + private val videoStreamsTestList = listOf( + videoStream("mpeg_4-720", MediaFormat.MPEG_4, "720p"), + videoStream("v3gpp-240", MediaFormat.v3GPP, "240p"), + videoStream("webm-480", MediaFormat.WEBM, "480p"), + videoStream("v3gpp-144", MediaFormat.v3GPP, "144p"), + videoStream("mpeg_4-360", MediaFormat.MPEG_4, "360p"), + videoStream("webm-360", MediaFormat.WEBM, "360p") + ) + + private val videoOnlyStreamsTestList = listOf( + videoStream("mpeg_4-720-1", MediaFormat.MPEG_4, "720p", isVideoOnly = true), + videoStream("mpeg_4-720-2", MediaFormat.MPEG_4, "720p", isVideoOnly = true), + videoStream("mpeg_4-2160", MediaFormat.MPEG_4, "2160p", isVideoOnly = true), + videoStream("mpeg_4-1440_60", MediaFormat.MPEG_4, "1440p60", isVideoOnly = true), + videoStream("webm-720_60", MediaFormat.WEBM, "720p60", isVideoOnly = true), + videoStream("mpeg_4-2160_60", MediaFormat.MPEG_4, "2160p60", isVideoOnly = true), + videoStream("mpeg_4-720_60", MediaFormat.MPEG_4, "720p60", isVideoOnly = true), + videoStream("mpeg_4-1080", MediaFormat.MPEG_4, "1080p", isVideoOnly = true), + videoStream("mpeg_4-1080_60", MediaFormat.MPEG_4, "1080p60", isVideoOnly = true) + ) + + // getVideoStreamIndex – standard quality strings + + @Test + fun `getVideoStreamIndex() must match exact resolution`() { + assertEquals( + 2, + ListHelper.getVideoStreamIndex("720p", MediaFormat.MPEG_4, videoStreamsList) + ) + } + + @Test + fun `getVideoStreamIndex() must match resolution with fps`() { + assertEquals( + 1, + ListHelper.getVideoStreamIndex("720p60", MediaFormat.MPEG_4, videoStreamsList) + ) + } + + // getVideoStreamIndex – bitrate-qualified resolution strings + + @Test + fun `getVideoStreamIndex() must ignore bitrate suffix with k unit`() { + assertEquals( + 1, + ListHelper.getVideoStreamIndex("720p60@1500k", MediaFormat.MPEG_4, videoStreamsList) + ) + } + + @Test + fun `getVideoStreamIndex() must ignore bitrate suffix with m unit`() { + assertEquals( + 0, + ListHelper.getVideoStreamIndex("1080p@2m", MediaFormat.MPEG_4, videoStreamsList) + ) + } + + // getVideoStreamIndex – comprehensive match combinations + + @Test + fun `getVideoStreamIndex() must match format, resolution and fps exactly`() { + assertEquals( + 1, + ListHelper.getVideoStreamIndex("720p60", MediaFormat.MPEG_4, videoStreamsList) + ) + } + + @Test + fun `getVideoStreamIndex() must fall back when fps does not match`() { + assertEquals( + 0, + ListHelper.getVideoStreamIndex("1080p60", MediaFormat.MPEG_4, videoStreamsList) + ) + } + + @Test + fun `getVideoStreamIndex() must fall back to 240p60 when 240p is not available`() { + assertEquals( + 6, + ListHelper.getVideoStreamIndex("240p", MediaFormat.v3GPP, videoStreamsList) + ) + } + + @Test + fun `getVideoStreamIndex() must fall back to other format when WEBM 720p60 is missing`() { + assertEquals( + 1, + ListHelper.getVideoStreamIndex("720p60", MediaFormat.WEBM, videoStreamsList) + ) + } + + @Test + fun `getVideoStreamIndex() must match 720p ignoring format mismatch`() { + assertEquals( + 2, + ListHelper.getVideoStreamIndex("720p", MediaFormat.WEBM, videoStreamsList) + ) + } + + @Test + fun `getVideoStreamIndex() must match 720p60 with null format`() { + assertEquals(1, ListHelper.getVideoStreamIndex("720p60", null, videoStreamsList)) + } + + @Test + fun `getVideoStreamIndex() must match 720p with null format`() { + assertEquals(2, ListHelper.getVideoStreamIndex("720p", null, videoStreamsList)) + } + + @Test + fun `getVideoStreamIndex() must fall back to 1080p when 1080p60 WEBM is missing`() { + assertEquals( + 0, + ListHelper.getVideoStreamIndex("1080p60", MediaFormat.WEBM, videoStreamsList) + ) + } + + @Test + fun `getVideoStreamIndex() must fall back to 240p60 when 240p WEBM is missing`() { + assertEquals( + 6, + ListHelper.getVideoStreamIndex("240p", MediaFormat.WEBM, videoStreamsList) + ) + } + + @Test + fun `getVideoStreamIndex() must fall back to 1080p when 1080p60 with null format is missing`() { + assertEquals(0, ListHelper.getVideoStreamIndex("1080p60", null, videoStreamsList)) + } + + @Test + fun `getVideoStreamIndex() must fall back to 240p60 when 240p with null format is missing`() { + assertEquals(6, ListHelper.getVideoStreamIndex("240p", null, videoStreamsList)) + } + + @Test + fun `getVideoStreamIndex() must fall back to 144p for 200p WEBM`() { + assertEquals( + 7, + ListHelper.getVideoStreamIndex("200p", MediaFormat.WEBM, videoStreamsList) + ) + } + + @Test + fun `getVideoStreamIndex() must fall back to 144p for 200p60 WEBM`() { + assertEquals( + 7, + ListHelper.getVideoStreamIndex("200p60", MediaFormat.WEBM, videoStreamsList) + ) + } + + @Test + fun `getVideoStreamIndex() must fall back to 144p for 200p MPEG4`() { + assertEquals( + 7, + ListHelper.getVideoStreamIndex("200p", MediaFormat.MPEG_4, videoStreamsList) + ) + } + + @Test + fun `getVideoStreamIndex() must fall back to 144p for 200p60 MPEG4`() { + assertEquals( + 7, + ListHelper.getVideoStreamIndex("200p60", MediaFormat.MPEG_4, videoStreamsList) + ) + } + + @Test + fun `getVideoStreamIndex() must fall back to 144p for 200p with null format`() { + assertEquals(7, ListHelper.getVideoStreamIndex("200p", null, videoStreamsList)) + } + + @Test + fun `getVideoStreamIndex() must fall back to 144p for 200p60 with null format`() { + assertEquals(7, ListHelper.getVideoStreamIndex("200p60", null, videoStreamsList)) + } + + @Test + fun `getVideoStreamIndex() must return -1 when no match exists`() { + assertEquals(-1, ListHelper.getVideoStreamIndex("100p", null, videoStreamsList)) + } + + @Test + fun `getVideoStreamIndex() must match resolution when format is null`() { + assertEquals(2, ListHelper.getVideoStreamIndex("720p", null, videoStreamsList)) + } + + // getVideoStreamIndex – bitrate tiebreaking + + @Test + fun `getVideoStreamIndex() must pick a valid index when streams have identical quality`() { + val streams = listOf( + videoStream("a-720-60-low", MediaFormat.MPEG_4, "720p60"), + videoStream("b-720-60-high", MediaFormat.MPEG_4, "720p60") + ) + val idx = ListHelper.getVideoStreamIndex("720p60", MediaFormat.MPEG_4, streams) + assertTrue(idx == 0 || idx == 1) + } + + // getDefaultResolutionIndex + + @Test + fun `getDefaultResolutionIndex() must return -1 for null list`() { + assertEquals( + -1, + ListHelper.getDefaultResolutionIndex( + "720p", + BEST_RESOLUTION_KEY, + MediaFormat.MPEG_4, + null + ) + ) + } + + @Test + fun `getDefaultResolutionIndex() must return -1 for empty list`() { + assertEquals( + -1, + ListHelper.getDefaultResolutionIndex( + "720p", + BEST_RESOLUTION_KEY, + MediaFormat.MPEG_4, + mutableListOf() + ) + ) + } + + @Test + fun `getDefaultResolutionIndex() must return best resolution when key is best_resolution`() { + val list = mutableListOf( + videoStream("a-360", MediaFormat.MPEG_4, "360p"), + videoStream("b-720", MediaFormat.MPEG_4, "720p") + ) + assertEquals( + 0, + ListHelper.getDefaultResolutionIndex( + BEST_RESOLUTION_KEY, + BEST_RESOLUTION_KEY, + MediaFormat.MPEG_4, + list + ) + ) + assertEquals("720p", list[0].getResolution()) + } + + @Test + fun `getDefaultResolutionIndex() must match resolution from bitrate-qualified string`() { + val list = mutableListOf( + videoStream("a-720", MediaFormat.MPEG_4, "720p"), + videoStream("b-480", MediaFormat.WEBM, "480p") + ) + val idx = ListHelper.getDefaultResolutionIndex( + "720p@2m", + BEST_RESOLUTION_KEY, + MediaFormat.MPEG_4, + list + ) + assertEquals("720p", list[idx].getResolution()) + } + + @Test + fun `getDefaultResolutionIndex() must fall back to first when no match exists`() { + val list = mutableListOf( + videoStream("a-360", MediaFormat.MPEG_4, "360p"), + videoStream("b-240", MediaFormat.WEBM, "240p") + ) + val idx = ListHelper.getDefaultResolutionIndex( + "1080p", + BEST_RESOLUTION_KEY, + MediaFormat.MPEG_4, + list + ) + assertEquals(0, idx) + } + + // getDefaultResolutionIndex – comprehensive + + @Test + fun `getDefaultResolutionIndex() must handle all resolution and format combinations`() { + val testList = mutableListOf( + videoStream("mpeg_4-720", MediaFormat.MPEG_4, "720p"), + videoStream("v3gpp-240", MediaFormat.v3GPP, "240p"), + videoStream("webm-480", MediaFormat.WEBM, "480p"), + videoStream("webm-240", MediaFormat.WEBM, "240p"), + videoStream("mpeg_4-240", MediaFormat.MPEG_4, "240p"), + videoStream("webm-144", MediaFormat.WEBM, "144p"), + videoStream("mpeg_4-360", MediaFormat.MPEG_4, "360p"), + videoStream("webm-360", MediaFormat.WEBM, "360p") + ) + + // Have resolution and the format + var result = testList[ + ListHelper.getDefaultResolutionIndex( + "720p", + BEST_RESOLUTION_KEY, + MediaFormat.MPEG_4, + testList + ) + ] + assertEquals("720p", result.getResolution()) + assertEquals(MediaFormat.MPEG_4, result.format) + + // Have resolution and the format + result = testList[ + ListHelper.getDefaultResolutionIndex( + "480p", + BEST_RESOLUTION_KEY, + MediaFormat.WEBM, + testList + ) + ] + assertEquals("480p", result.getResolution()) + assertEquals(MediaFormat.WEBM, result.format) + + // Have resolution but not the format + result = testList[ + ListHelper.getDefaultResolutionIndex( + "480p", + BEST_RESOLUTION_KEY, + MediaFormat.MPEG_4, + testList + ) + ] + assertEquals("480p", result.getResolution()) + assertEquals(MediaFormat.WEBM, result.format) + + // Have resolution and the format + result = testList[ + ListHelper.getDefaultResolutionIndex( + "240p", + BEST_RESOLUTION_KEY, + MediaFormat.WEBM, + testList + ) + ] + assertEquals("240p", result.getResolution()) + assertEquals(MediaFormat.WEBM, result.format) + + // The best resolution + result = testList[ + ListHelper.getDefaultResolutionIndex( + BEST_RESOLUTION_KEY, + BEST_RESOLUTION_KEY, + MediaFormat.WEBM, + testList + ) + ] + assertEquals("720p", result.getResolution()) + assertEquals(MediaFormat.MPEG_4, result.format) + + // Doesn't have the 60fps variant and format + result = testList[ + ListHelper.getDefaultResolutionIndex( + "720p60", + BEST_RESOLUTION_KEY, + MediaFormat.WEBM, + testList + ) + ] + assertEquals("720p", result.getResolution()) + assertEquals(MediaFormat.MPEG_4, result.format) + + // Doesn't have the 60fps variant + result = testList[ + ListHelper.getDefaultResolutionIndex( + "480p60", + BEST_RESOLUTION_KEY, + MediaFormat.WEBM, + testList + ) + ] + assertEquals("480p", result.getResolution()) + assertEquals(MediaFormat.WEBM, result.format) + + // Doesn't have the resolution, will return the best one + result = testList[ + ListHelper.getDefaultResolutionIndex( + "2160p60", + BEST_RESOLUTION_KEY, + MediaFormat.WEBM, + testList + ) + ] + assertEquals("720p", result.getResolution()) + assertEquals(MediaFormat.MPEG_4, result.format) + } + + // getSortedStreamVideosList + + @Test + fun `getSortedStreamVideosList() must sort in ascending order`() { + val result = ListHelper.getSortedStreamVideosList( + MediaFormat.MPEG_4, + true, + videoStreamsTestList, + videoOnlyStreamsTestList, + true, + false + ) + val expected = listOf( + "144p", "240p", "360p", "480p", "720p", "720p60", + "1080p", "1080p60", "1440p60", "2160p", "2160p60" + ) + assertEquals(expected.size, result.size) + for (i in result.indices) { + assertEquals(expected[i], result[i].getResolution()) + } + } + + @Test + fun `getSortedStreamVideosList() must sort in descending order`() { + val result = ListHelper.getSortedStreamVideosList( + MediaFormat.MPEG_4, + true, + videoStreamsTestList, + videoOnlyStreamsTestList, + false, + false + ) + val expected = listOf( + "2160p60", "2160p", "1440p60", "1080p60", "1080p", + "720p60", "720p", "480p", "360p", "240p", "144p" + ) + assertEquals(expected.size, result.size) + for (i in result.indices) { + assertEquals(expected[i], result[i].getResolution()) + } + } + + @Test + fun `getSortedStreamVideosList() must prefer video-only streams when requested`() { + val result = ListHelper.getSortedStreamVideosList( + MediaFormat.MPEG_4, + true, + null, + videoOnlyStreamsTestList, + true, + true + ) + val expected = listOf( + "720p", + "720p60", + "1080p", + "1080p60", + "1440p60", + "2160p", + "2160p60" + ) + assertEquals(expected.size, result.size) + for (i in result.indices) { + assertEquals(expected[i], result[i].getResolution()) + assertTrue(result[i].isVideoOnly) + } + } + + @Test + fun `getSortedStreamVideosList() must return mixed streams when no video-only are available`() { + val result = ListHelper.getSortedStreamVideosList( + MediaFormat.MPEG_4, + true, + videoStreamsTestList, + null, + false, + true + ) + val expected = listOf("720p", "480p", "360p", "240p", "144p") + assertEquals(expected.size, result.size) + for (i in result.indices) { + assertEquals(expected[i], result[i].getResolution()) + assertFalse(result[i].isVideoOnly) + } + } + + @Test + fun `getSortedStreamVideosList() must set correct video-only flag for both types`() { + val result = ListHelper.getSortedStreamVideosList( + MediaFormat.MPEG_4, + true, + videoStreamsTestList, + videoOnlyStreamsTestList, + true, + true + ) + val expected = listOf( + "144p", "240p", "360p", "480p", "720p", "720p60", + "1080p", "1080p60", "1440p60", "2160p", "2160p60" + ) + val expectedVideoOnly = listOf( + "720p", + "720p60", + "1080p", + "1080p60", + "1440p60", + "2160p", + "2160p60" + ) + + assertEquals(expected.size, result.size) + for (i in result.indices) { + assertEquals(expected[i], result[i].getResolution()) + assertEquals( + expectedVideoOnly.contains(result[i].getResolution()), + result[i].isVideoOnly + ) + } + } + + @Test + fun `getSortedStreamVideosList() must exclude high resolutions when disabled`() { + val result = ListHelper.getSortedStreamVideosList( + MediaFormat.MPEG_4, + false, + videoStreamsTestList, + videoOnlyStreamsTestList, + false, + false + ) + val expected = listOf( + "1080p60", + "1080p", + "720p60", + "720p", + "480p", + "360p", + "240p", + "144p" + ) + assertEquals(expected.size, result.size) + for (i in result.indices) { + assertEquals(expected[i], result[i].getResolution()) + } + } + + @Test + fun `getSortedStreamVideosList() must deduplicate and prefer the default format`() { + val videoStreams = listOf( + videoStream("webm-360", MediaFormat.WEBM, "360p"), + videoStream("mpeg4-360", MediaFormat.MPEG_4, "360p") + ) + val result = ListHelper.getSortedStreamVideosList( + MediaFormat.MPEG_4, + false, + videoStreams, + null, + true, + false + ) + assertEquals(1, result.size) + assertEquals(MediaFormat.MPEG_4, result[0].format) + } + + @Test + fun `getSortedStreamVideosList() must return empty list for null inputs`() { + val result = ListHelper.getSortedStreamVideosList( + MediaFormat.MPEG_4, + false, + null, + null, + true, + false + ) + assertTrue(result.isEmpty()) + } + + @Test + fun `getSortedStreamVideosList() must return empty list for empty inputs`() { + val result = ListHelper.getSortedStreamVideosList( + MediaFormat.MPEG_4, + false, + emptyList(), + emptyList(), + true, + false + ) + assertTrue(result.isEmpty()) + } + + @Test + fun `getSortedStreamVideosList() must filter out 1440p and 2160p when high res is disabled`() { + val streams = listOf( + videoStream("a-720", MediaFormat.MPEG_4, "720p"), + videoStream("b-2160", MediaFormat.MPEG_4, "2160p"), + videoStream("c-1440", MediaFormat.MPEG_4, "1440p") + ) + val result = ListHelper.getSortedStreamVideosList( + MediaFormat.MPEG_4, + false, + streams, + null, + true, + false + ) + assertEquals(1, result.size) + assertEquals("720p", result[0].getResolution()) + } + + @Test + fun `getSortedStreamVideosList() must include high resolutions when enabled`() { + val streams = listOf( + videoStream("a-720", MediaFormat.MPEG_4, "720p"), + videoStream("b-2160", MediaFormat.MPEG_4, "2160p") + ) + val result = ListHelper.getSortedStreamVideosList( + MediaFormat.MPEG_4, + true, + streams, + null, + true, + false + ) + assertEquals(2, result.size) + } + + // Playable streams filtering + + @Test + fun `getPlayableStreams() must exclude torrent streams`() { + val streams = listOf( + videoStream( + "a", + MediaFormat.MPEG_4, + "720p", + deliveryMethod = DeliveryMethod.PROGRESSIVE_HTTP + ), + videoStream( + "b", + MediaFormat.MPEG_4, + "360p", + deliveryMethod = DeliveryMethod.TORRENT + ) + ) + val result = ListHelper.getPlayableStreams(streams, 0) + assertEquals(1, result.size) + assertEquals("720p", result[0].getResolution()) + } + + @Test + fun `getPlayableStreams() must exclude HLS OPUS streams`() { + val streams = listOf( + videoStream( + "a", + MediaFormat.MPEG_4, + "720p", + deliveryMethod = DeliveryMethod.HLS + ), + videoStream( + "b", + MediaFormat.OPUS, + "360p", + deliveryMethod = DeliveryMethod.HLS + ) + ) + val result = ListHelper.getPlayableStreams(streams, 0) + assertEquals(1, result.size) + assertEquals("720p", result[0].getResolution()) + } + + @Test + fun `getPlayableStreams() must pass all streams for non-YouTube services`() { + val streams = listOf( + videoStream( + "a", + MediaFormat.MPEG_4, + "720p", + deliveryMethod = DeliveryMethod.PROGRESSIVE_HTTP + ) + ) + val result = ListHelper.getPlayableStreams(streams, 999) + assertEquals(1, result.size) + } + + @Test + fun `getPlayableStreams() must filter by supported itag for YouTube`() { + val youtubeServiceId = ServiceList.YouTube.serviceId + val supportedItag = ItagItem.getItag(18) + val streams = listOf( + videoStream( + "a-supported", + MediaFormat.MPEG_4, + "360p", + itagItem = supportedItag + ) + ) + val result = ListHelper.getPlayableStreams(streams, youtubeServiceId) + assertEquals(1, result.size) + } + + @Test + fun `getPlayableStreams() must return empty list for null input`() { + val result = ListHelper.getPlayableStreams(null, 0) + assertTrue(result.isEmpty()) + } + + // Stream delivery filtering + + @Test + fun `getStreamsOfSpecifiedDelivery() must return only streams with matching delivery`() { + val streams = listOf( + videoStream( + "a-dash", + MediaFormat.MPEG_4, + "720p", + deliveryMethod = DeliveryMethod.DASH + ), + videoStream( + "b-hls", + MediaFormat.MPEG_4, + "480p", + deliveryMethod = DeliveryMethod.HLS + ), + videoStream( + "c-dash", + MediaFormat.WEBM, + "360p", + deliveryMethod = DeliveryMethod.DASH + ) + ) + val result = ListHelper.getStreamsOfSpecifiedDelivery(streams, DeliveryMethod.DASH) + assertEquals(2, result.size) + assertTrue(result.all { it.deliveryMethod == DeliveryMethod.DASH }) + } + + @Test + fun `getStreamsOfSpecifiedDelivery() must return empty list for null input`() { + val result = + ListHelper.getStreamsOfSpecifiedDelivery(null, DeliveryMethod.DASH) + assertTrue(result.isEmpty()) + } + + @Test + fun `getUrlAndNonTorrentStreams() must exclude torrent streams`() { + val streams = listOf( + videoStream( + "a", + MediaFormat.MPEG_4, + "720p", + deliveryMethod = DeliveryMethod.PROGRESSIVE_HTTP + ), + videoStream( + "b", + MediaFormat.MPEG_4, + "360p", + deliveryMethod = DeliveryMethod.TORRENT + ), + videoStream( + "c", + MediaFormat.MPEG_4, + "480p", + deliveryMethod = DeliveryMethod.HLS + ) + ) + val result = ListHelper.getUrlAndNonTorrentStreams(streams) + assertEquals(2, result.size) + assertTrue(result.none { it.deliveryMethod == DeliveryMethod.TORRENT }) + } + + @Test + fun `getUrlAndNonTorrentStreams() must exclude non-URL streams`() { + val streams = listOf( + videoStream("a", MediaFormat.MPEG_4, "720p", isUrl = true), + videoStream("b", MediaFormat.MPEG_4, "360p", isUrl = false) + ) + val result = ListHelper.getUrlAndNonTorrentStreams(streams) + assertEquals(1, result.size) + assertEquals("720p", result[0].getResolution()) + } + + @Test + fun `getUrlAndNonTorrentStreams() must return empty list for null input`() { + val result = ListHelper.getUrlAndNonTorrentStreams(null) + assertTrue(result.isEmpty()) + } + + // Audio – highest quality format selection + + @Test + fun `getAudioIndexByHighestRank() must select highest quality M4A stream`() { + val cmp = ListHelper.getAudioFormatComparator(MediaFormat.M4A, false) + val stream = + audioStreamsList[ListHelper.getAudioIndexByHighestRank(audioStreamsList, cmp)] + assertEquals(320, stream.averageBitrate) + assertEquals(MediaFormat.M4A, stream.format) + } + + @Test + fun `getAudioIndexByHighestRank() must select highest quality WEBMA stream`() { + val cmp = ListHelper.getAudioFormatComparator(MediaFormat.WEBMA, false) + val stream = + audioStreamsList[ListHelper.getAudioIndexByHighestRank(audioStreamsList, cmp)] + assertEquals(320, stream.averageBitrate) + assertEquals(MediaFormat.WEBMA, stream.format) + } + + @Test + fun `getAudioIndexByHighestRank() must select highest quality MP3 stream`() { + val cmp = ListHelper.getAudioFormatComparator(MediaFormat.MP3, false) + val stream = + audioStreamsList[ListHelper.getAudioIndexByHighestRank(audioStreamsList, cmp)] + assertEquals(192, stream.averageBitrate) + assertEquals(MediaFormat.MP3, stream.format) + } + + @Test + fun `getAudioIndexByHighestRank() must fall back to highest bitrate when preferred format is absent`() { + val cmp = ListHelper.getAudioFormatComparator(MediaFormat.MP3, false) + val testList = listOf( + audioStream("m4a-128", MediaFormat.M4A, 128), + audioStream("webma-192", MediaFormat.WEBMA, 192) + ) + val stream = testList[ListHelper.getAudioIndexByHighestRank(testList, cmp)] + assertEquals(192, stream.averageBitrate) + assertEquals(MediaFormat.WEBMA, stream.format) + } + + @Test + fun `getAudioIndexByHighestRank() must prefer highest quality format at equal bitrates`() { + val cmp = ListHelper.getAudioFormatComparator(MediaFormat.MP3, false) + val testList = mutableListOf( + audioStream("webma-192-1", MediaFormat.WEBMA, 192), + audioStream("m4a-192-1", MediaFormat.M4A, 192), + audioStream("webma-192-2", MediaFormat.WEBMA, 192), + audioStream("m4a-192-2", MediaFormat.M4A, 192), + audioStream("webma-192-3", MediaFormat.WEBMA, 192), + audioStream("m4a-192-3", MediaFormat.M4A, 192), + audioStream("webma-192-4", MediaFormat.WEBMA, 192) + ) + // No MP3; should fallback to highest bitrate + highest quality format (M4A) + var stream = testList[ListHelper.getAudioIndexByHighestRank(testList, cmp)] + assertEquals(192, stream.averageBitrate) + assertEquals(MediaFormat.M4A, stream.format) + + // Adding another WEBMA stream should have no impact + testList.add(audioStream("webma-192-5", MediaFormat.WEBMA, 192)) + stream = testList[ListHelper.getAudioIndexByHighestRank(testList, cmp)] + assertEquals(192, stream.averageBitrate) + assertEquals(MediaFormat.M4A, stream.format) + } + + @Test + fun `getAudioIndexByHighestRank() must return -1 for null or empty list`() { + val cmp = ListHelper.getAudioFormatComparator(null, false) + assertEquals(-1, ListHelper.getAudioIndexByHighestRank(null, cmp)) + assertEquals(-1, ListHelper.getAudioIndexByHighestRank(emptyList(), cmp)) + } + + // Audio – lowest quality (limit data usage) format selection + + @Test + fun `getAudioIndexByHighestRank() must select lowest quality M4A when limiting data`() { + val cmp = ListHelper.getAudioFormatComparator(MediaFormat.M4A, true) + val stream = + audioStreamsList[ListHelper.getAudioIndexByHighestRank(audioStreamsList, cmp)] + assertEquals(128, stream.averageBitrate) + assertEquals(MediaFormat.M4A, stream.format) + } + + @Test + fun `getAudioIndexByHighestRank() must select lowest quality WEBMA when limiting data`() { + val cmp = ListHelper.getAudioFormatComparator(MediaFormat.WEBMA, true) + val stream = + audioStreamsList[ListHelper.getAudioIndexByHighestRank(audioStreamsList, cmp)] + assertEquals(64, stream.averageBitrate) + assertEquals(MediaFormat.WEBMA, stream.format) + } + + @Test + fun `getAudioIndexByHighestRank() must select lowest quality MP3 when limiting data`() { + val cmp = ListHelper.getAudioFormatComparator(MediaFormat.MP3, true) + val stream = + audioStreamsList[ListHelper.getAudioIndexByHighestRank(audioStreamsList, cmp)] + assertEquals(64, stream.averageBitrate) + assertEquals(MediaFormat.MP3, stream.format) + } + + @Test + fun `getAudioIndexByHighestRank() must fall back to lowest bitrate when preferred format is absent and limiting data`() { + val cmp = ListHelper.getAudioFormatComparator(MediaFormat.MP3, true) + val testList = mutableListOf( + audioStream("m4a-128", MediaFormat.M4A, 128), + audioStream("webma-192-1", MediaFormat.WEBMA, 192) + ) + // No MP3, fallback to most compact (lowest bitrate) + var stream = testList[ListHelper.getAudioIndexByHighestRank(testList, cmp)] + assertEquals(128, stream.averageBitrate) + assertEquals(MediaFormat.M4A, stream.format) + + // WEBMA is more efficient than M4A at same bitrate + testList.add(audioStream("webma-128", MediaFormat.WEBMA, 128)) + stream = testList[ListHelper.getAudioIndexByHighestRank(testList, cmp)] + assertEquals(128, stream.averageBitrate) + assertEquals(MediaFormat.WEBMA, stream.format) + } + + @Test + fun `getAudioIndexByHighestRank() must prefer most efficient format at equal bitrates when limiting data`() { + val cmp = ListHelper.getAudioFormatComparator(MediaFormat.MP3, true) + val testList = listOf( + audioStream("webma-192-1", MediaFormat.WEBMA, 192), + audioStream("m4a-192-1", MediaFormat.M4A, 192), + audioStream("webma-256", MediaFormat.WEBMA, 256), + audioStream("m4a-192-2", MediaFormat.M4A, 192), + audioStream("webma-192-2", MediaFormat.WEBMA, 192), + audioStream("m4a-192-3", MediaFormat.M4A, 192) + ) + // No MP3, fallback to most compact: lowest bitrate + most efficient format (WEBMA) + var stream = testList[ListHelper.getAudioIndexByHighestRank(testList, cmp)] + assertEquals(192, stream.averageBitrate) + assertEquals(MediaFormat.WEBMA, stream.format) + + // Same with null preferred format + val cmpNull = ListHelper.getAudioFormatComparator(null, true) + stream = testList[ListHelper.getAudioIndexByHighestRank(testList, cmpNull)] + assertEquals(192, stream.averageBitrate) + assertEquals(MediaFormat.WEBMA, stream.format) + } + + @Test + fun `getAudioIndexByHighestRank() must return -1 for null or empty list when limiting data`() { + val cmp = ListHelper.getAudioFormatComparator(null, false) + assertEquals(-1, ListHelper.getAudioIndexByHighestRank(null, cmp)) + assertEquals(-1, ListHelper.getAudioIndexByHighestRank(emptyList(), cmp)) + } + + // Audio – single element edge case + + @Test + fun `getAudioIndexByHighestRank() must return 0 for a single-element list`() { + val streams = listOf(audioStream("only", MediaFormat.M4A, 128)) + val cmp = ListHelper.getAudioFormatComparator(MediaFormat.M4A, false) + assertEquals(0, ListHelper.getAudioIndexByHighestRank(streams, cmp)) + } + + // Audio track selection + + @Test + fun `getAudioTrackComparator() must select original English track for English locale`() { + val cmp = ListHelper.getAudioTrackComparator(Locale.ENGLISH, false, false) + val stream = + audioTracksList[ListHelper.getAudioIndexByHighestRank(audioTracksList, cmp)] + assertEquals("en.or", stream.id) + } + + @Test + fun `getAudioTrackComparator() must select dubbed German track for German locale`() { + val cmp = ListHelper.getAudioTrackComparator(Locale.GERMAN, false, false) + val stream = + audioTracksList[ListHelper.getAudioIndexByHighestRank(audioTracksList, cmp)] + assertEquals("de.du", stream.id) + } + + @Test + fun `getAudioTrackComparator() must prefer original over German dubbed when preferOriginal is set`() { + val cmp = ListHelper.getAudioTrackComparator(Locale.GERMAN, true, false) + val stream = + audioTracksList[ListHelper.getAudioIndexByHighestRank(audioTracksList, cmp)] + assertEquals("en.or", stream.id) + } + + @Test + fun `getAudioTrackComparator() must select descriptive track when preferDescriptive is set`() { + val cmp = ListHelper.getAudioTrackComparator(Locale.ENGLISH, false, true) + val stream = + audioTracksList[ListHelper.getAudioIndexByHighestRank(audioTracksList, cmp)] + assertEquals("en.ds", stream.id) + } + + @Test + fun `getAudioTrackComparator() must fall back to original for unavailable language`() { + val cmp = ListHelper.getAudioTrackComparator(Locale.JAPANESE, true, false) + val stream = + audioTracksList[ListHelper.getAudioIndexByHighestRank(audioTracksList, cmp)] + assertEquals("en.or", stream.id) + } + + companion object { + private const val BEST_RESOLUTION_KEY = "best_resolution" + + private fun videoStream( + id: String, + format: MediaFormat?, + resolution: String, + isVideoOnly: Boolean = false, + deliveryMethod: DeliveryMethod = DeliveryMethod.PROGRESSIVE_HTTP, + itagItem: ItagItem? = null, + isUrl: Boolean = true + ): VideoStream { + return VideoStream.Builder() + .setId(id) + .setContent("", isUrl) + .setIsVideoOnly(isVideoOnly) + .setResolution(resolution) + .setMediaFormat(format) + .setDeliveryMethod(deliveryMethod) + .apply { if (itagItem != null) setItagItem(itagItem) } + .build() + } + + private fun audioStream( + id: String, + format: MediaFormat?, + averageBitrate: Int + ): AudioStream { + return AudioStream.Builder() + .setId(id) + .setContent("", true) + .setMediaFormat(format) + .setAverageBitrate(averageBitrate) + .build() + } + + private fun audioTrack( + id: String, + trackId: String?, + locale: Locale?, + trackType: AudioTrackType? + ): AudioStream { + return AudioStream.Builder() + .setId(id) + .setContent("", true) + .setMediaFormat(MediaFormat.M4A) + .setAverageBitrate(128) + .setAudioTrackId(trackId) + .setAudioLocale(locale) + .setAudioTrackType(trackType) + .build() + } + } +}