package org.schabi.newpipe.extractor.services.peertube.extractors;

import static org.schabi.newpipe.extractor.services.peertube.PeertubeParsingHelper.getAvatarsFromOwnerAccountOrVideoChannelObject;
import static org.schabi.newpipe.extractor.services.peertube.PeertubeParsingHelper.getThumbnailsFromPlaylistOrVideoItem;
import static org.schabi.newpipe.extractor.stream.AudioStream.UNKNOWN_BITRATE;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;

import com.grack.nanojson.JsonArray;
import com.grack.nanojson.JsonObject;
import com.grack.nanojson.JsonParser;
import com.grack.nanojson.JsonParserException;
import org.schabi.newpipe.extractor.Image;
import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.downloader.Downloader;
import org.schabi.newpipe.extractor.downloader.Response;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
import org.schabi.newpipe.extractor.linkhandler.LinkHandler;
import org.schabi.newpipe.extractor.localization.DateWrapper;
import org.schabi.newpipe.extractor.services.peertube.PeertubeParsingHelper;
import org.schabi.newpipe.extractor.services.peertube.linkHandler.PeertubeSearchQueryHandlerFactory;
import org.schabi.newpipe.extractor.services.peertube.linkHandler.PeertubeStreamLinkHandlerFactory;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.DeliveryMethod;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.extractor.stream.Frameset;
import org.schabi.newpipe.extractor.stream.Stream;
import org.schabi.newpipe.extractor.stream.StreamExtractor;
import org.schabi.newpipe.extractor.stream.StreamInfoItemsCollector;
import org.schabi.newpipe.extractor.stream.StreamSegment;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.extractor.stream.SubtitlesStream;
import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.extractor.utils.JsonUtils;
import org.schabi.newpipe.extractor.utils.Utils;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

public class PeertubeStreamExtractor extends StreamExtractor {
    private static final String ACCOUNT_HOST = "account.host";
    private static final String ACCOUNT_NAME = "account.name";
    private static final String FILES = "files";
    private static final String FILE_DOWNLOAD_URL = "fileDownloadUrl";
    private static final String FILE_URL = "fileUrl";
    private static final String PLAYLIST_URL = "playlistUrl";
    private static final String RESOLUTION_ID = "resolution.id";
    private static final String STREAMING_PLAYLISTS = "streamingPlaylists";

    private final String baseUrl;
    private JsonObject json;

    private final List<SubtitlesStream> subtitles = new ArrayList<>();
    private final List<AudioStream> audioStreams = new ArrayList<>();
    private final List<VideoStream> videoStreams = new ArrayList<>();

    private ParsingException subtitlesException = null;

    public PeertubeStreamExtractor(final StreamingService service, final LinkHandler linkHandler)
            throws ParsingException {
        super(service, linkHandler);
        this.baseUrl = getBaseUrl();
    }

    @Override
    public String getTextualUploadDate() throws ParsingException {
        return JsonUtils.getString(json, "publishedAt");
    }

    @Override
    public DateWrapper getUploadDate() throws ParsingException {
        final String textualUploadDate = getTextualUploadDate();

        if (textualUploadDate == null) {
            return null;
        }

        return new DateWrapper(PeertubeParsingHelper.parseDateFrom(textualUploadDate));
    }

    @Nonnull
    @Override
    public List<Image> getThumbnails() throws ParsingException {
        return getThumbnailsFromPlaylistOrVideoItem(baseUrl, json);
    }

    @Nonnull
    @Override
    public Description getDescription() throws ParsingException {
        String text;
        try {
            text = JsonUtils.getString(json, "description");
        } catch (final ParsingException e) {
            return Description.EMPTY_DESCRIPTION;
        }
        if (text.length() == 250 && text.substring(247).equals("...")) {
            // If description is shortened, get full description
            final Downloader dl = NewPipe.getDownloader();
            try {
                final Response response = dl.get(baseUrl
                        + PeertubeStreamLinkHandlerFactory.VIDEO_API_ENDPOINT
                        + getId() + "/description");
                final JsonObject jsonObject = JsonParser.object().from(response.responseBody());
                text = JsonUtils.getString(jsonObject, "description");
            } catch (final IOException | ReCaptchaException | JsonParserException ignored) {
                // Something went wrong when getting the full description, use the shortened one
            }
        }
        return new Description(text, Description.MARKDOWN);
    }

    @Override
    public int getAgeLimit() throws ParsingException {
        final boolean isNSFW = JsonUtils.getBoolean(json, "nsfw");
        if (isNSFW) {
            return 18;
        } else {
            return NO_AGE_LIMIT;
        }
    }

    @Override
    public long getLength() {
        return json.getLong("duration");
    }

    @Override
    public long getTimeStamp() throws ParsingException {
        final long timestamp = getTimestampSeconds(
                "((#|&|\\?)start=\\d{0,3}h?\\d{0,3}m?\\d{1,3}s?)");

        if (timestamp == -2) {
            // regex for timestamp was not found
            return 0;
        } else {
            return timestamp;
        }
    }

    @Override
    public long getViewCount() {
        return json.getLong("views");
    }

    @Override
    public long getLikeCount() {
        return json.getLong("likes");
    }

    @Override
    public long getDislikeCount() {
        return json.getLong("dislikes");
    }

    @Nonnull
    @Override
    public String getUploaderUrl() throws ParsingException {
        final String name = JsonUtils.getString(json, ACCOUNT_NAME);
        final String host = JsonUtils.getString(json, ACCOUNT_HOST);
        return getService().getChannelLHFactory().fromId("accounts/" + name + "@" + host, baseUrl)
                .getUrl();
    }

    @Nonnull
    @Override
    public String getUploaderName() throws ParsingException {
        return JsonUtils.getString(json, "account.displayName");
    }

    @Nonnull
    @Override
    public List<Image> getUploaderAvatars() {
        return getAvatarsFromOwnerAccountOrVideoChannelObject(baseUrl, json.getObject("account"));
    }

    @Nonnull
    @Override
    public String getSubChannelUrl() throws ParsingException {
        return JsonUtils.getString(json, "channel.url");
    }

    @Nonnull
    @Override
    public String getSubChannelName() throws ParsingException {
        return JsonUtils.getString(json, "channel.displayName");
    }

    @Nonnull
    @Override
    public List<Image> getSubChannelAvatars() {
        return getAvatarsFromOwnerAccountOrVideoChannelObject(baseUrl, json.getObject("channel"));
    }

    @Nonnull
    @Override
    public String getHlsUrl() {
        assertPageFetched();

        if (getStreamType() == StreamType.VIDEO_STREAM
                && !isNullOrEmpty(json.getObject(FILES))) {
            return json.getObject(FILES).getString(PLAYLIST_URL, "");
        }

        return json.getArray(STREAMING_PLAYLISTS).getObject(0).getString(PLAYLIST_URL, "");
    }

    @Override
    public List<AudioStream> getAudioStreams() throws ParsingException {
        assertPageFetched();

        /*
        Some videos have audio streams; others don't.
        So an audio stream may be available if a video stream is available.
        Audio streams are also not returned as separated streams for livestreams.
        That's why the extraction of audio streams is only run when there are video streams
        extracted and when the content is not a livestream.
         */
        if (audioStreams.isEmpty() && videoStreams.isEmpty()
                && getStreamType() == StreamType.VIDEO_STREAM) {
            getStreams();
        }

        return audioStreams;
    }

    @Override
    public List<VideoStream> getVideoStreams() throws ExtractionException {
        assertPageFetched();

        if (videoStreams.isEmpty()) {
            if (getStreamType() == StreamType.VIDEO_STREAM) {
                getStreams();
            } else {
                extractLiveVideoStreams();
            }
        }

        return videoStreams;
    }

    @Override
    public List<VideoStream> getVideoOnlyStreams() {
        return Collections.emptyList();
    }

    @Nonnull
    @Override
    public List<SubtitlesStream> getSubtitlesDefault() throws ParsingException {
        if (subtitlesException != null) {
            throw subtitlesException;
        }
        return subtitles;
    }

    @Nonnull
    @Override
    public List<SubtitlesStream> getSubtitles(final MediaFormat format) throws ParsingException {
        if (subtitlesException != null) {
            throw subtitlesException;
        }
        return subtitles.stream()
                .filter(sub -> sub.getFormat() == format)
                .collect(Collectors.toList());
    }

    @Override
    public StreamType getStreamType() {
        return json.getBoolean("isLive") ? StreamType.LIVE_STREAM : StreamType.VIDEO_STREAM;
    }

    @Nullable
    @Override
    public StreamInfoItemsCollector getRelatedItems() throws IOException, ExtractionException {
        final List<String> tags = getTags();
        final String apiUrl;
        if (tags.isEmpty()) {
            apiUrl = baseUrl + "/api/v1/accounts/" + JsonUtils.getString(json, ACCOUNT_NAME)
                    + "@" + JsonUtils.getString(json, ACCOUNT_HOST)
                    + "/videos?start=0&count=8";
        } else {
            apiUrl = getRelatedItemsUrl(tags);
        }

        if (Utils.isBlank(apiUrl)) {
            return null;
        } else {
            final StreamInfoItemsCollector collector = new StreamInfoItemsCollector(
                    getServiceId());
            getStreamsFromApi(collector, apiUrl);
            return collector;
        }
    }

    @Nonnull
    @Override
    public List<String> getTags() {
        return JsonUtils.getStringListFromJsonArray(json.getArray("tags"));
    }

    @Nonnull
    @Override
    public String getSupportInfo() {
        try {
            return JsonUtils.getString(json, "support");
        } catch (final ParsingException e) {
            return "";
        }
    }

    @Nonnull
    @Override
    public List<StreamSegment> getStreamSegments() throws ParsingException {
        final List<StreamSegment> segments = new ArrayList<>();
        final JsonObject segmentsJson;
        try {
            segmentsJson = fetchSubApiContent("chapters");
        } catch (final IOException | ReCaptchaException e) {
            throw new ParsingException("Could not get stream segments", e);
        }
        if (segmentsJson != null && segmentsJson.has("chapters")) {
            final JsonArray segmentsArray = segmentsJson.getArray("chapters");
            for (int i = 0; i < segmentsArray.size(); i++) {
                final JsonObject segmentObject = segmentsArray.getObject(i);
                segments.add(new StreamSegment(
                        segmentObject.getString("title"),
                        segmentObject.getInt("timecode")));
            }
        }

        return segments;
    }

    @Nonnull
    @Override
    public List<Frameset> getFrames() throws ExtractionException {
        final List<Frameset> framesets = new ArrayList<>();
        final JsonObject storyboards;
        try {
            storyboards = fetchSubApiContent("storyboards");
        } catch (final IOException | ReCaptchaException e) {
            throw new ExtractionException("Could not get frames", e);
        }
        if (storyboards != null && storyboards.has("storyboards")) {
            final JsonArray storyboardsArray = storyboards.getArray("storyboards");
            for (final Object storyboard : storyboardsArray) {
                if (storyboard instanceof JsonObject) {
                    final JsonObject storyboardObject = (JsonObject) storyboard;
                    final String url = storyboardObject.getString("storyboardPath");
                    final int width = storyboardObject.getInt("spriteWidth");
                    final int height = storyboardObject.getInt("spriteHeight");
                    final int totalWidth = storyboardObject.getInt("totalWidth");
                    final int totalHeight = storyboardObject.getInt("totalHeight");
                    final int framesPerPageX = totalWidth / width;
                    final int framesPerPageY = totalHeight / height;
                    final int count = framesPerPageX * framesPerPageY;
                    final int durationPerFrame = storyboardObject.getInt("spriteDuration") * 1000;

                    framesets.add(new Frameset(
                            // there is only one composite image per video containing all frames
                            List.of(baseUrl + url),
                            width, height, count,
                            durationPerFrame, framesPerPageX, framesPerPageY));
                }
            }
        }

        return framesets;
    }

    @Nonnull
    private String getRelatedItemsUrl(@Nonnull final List<String> tags) {
        final String url = baseUrl + PeertubeSearchQueryHandlerFactory.SEARCH_ENDPOINT_VIDEOS;
        final StringBuilder params = new StringBuilder();
        params.append("start=0&count=8&sort=-createdAt");
        for (final String tag : tags) {
            params.append("&tagsOneOf=").append(Utils.encodeUrlUtf8(tag));
        }
        return url + "?" + params;
    }

    private void getStreamsFromApi(final StreamInfoItemsCollector collector, final String apiUrl)
            throws IOException, ReCaptchaException, ParsingException {
        final Response response = getDownloader().get(apiUrl);
        JsonObject relatedVideosJson = null;
        if (response != null && !Utils.isBlank(response.responseBody())) {
            try {
                relatedVideosJson = JsonParser.object().from(response.responseBody());
            } catch (final JsonParserException e) {
                throw new ParsingException("Could not parse json data for related videos", e);
            }
        }

        if (relatedVideosJson != null) {
            collectStreamsFrom(collector, relatedVideosJson);
        }
    }

    private void collectStreamsFrom(final StreamInfoItemsCollector collector,
                                    final JsonObject jsonObject) throws ParsingException {
        final JsonArray contents;
        try {
            contents = (JsonArray) JsonUtils.getValue(jsonObject, "data");
        } catch (final Exception e) {
            throw new ParsingException("Could not extract related videos", e);
        }

        for (final Object c : contents) {
            if (c instanceof JsonObject) {
                final JsonObject item = (JsonObject) c;
                final PeertubeStreamInfoItemExtractor extractor =
                        new PeertubeStreamInfoItemExtractor(item, baseUrl);
                // Do not add the same stream in related streams
                if (!extractor.getUrl().equals(getUrl())) {
                    collector.commit(extractor);
                }
            }
        }
    }

    @Override
    public void onFetchPage(@Nonnull final Downloader downloader)
            throws IOException, ExtractionException {
        final Response response = downloader.get(
                baseUrl + PeertubeStreamLinkHandlerFactory.VIDEO_API_ENDPOINT + getId());
        if (response != null) {
            setInitialData(response.responseBody());
        } else {
            throw new ExtractionException("Could not extract PeerTube channel data");
        }

        loadSubtitles();
    }

    private void setInitialData(final String responseBody) throws ExtractionException {
        try {
            json = JsonParser.object().from(responseBody);
        } catch (final JsonParserException e) {
            throw new ExtractionException("Could not extract PeerTube stream data", e);
        }
        if (json == null) {
            throw new ExtractionException("Could not extract PeerTube stream data");
        }
        PeertubeParsingHelper.validate(json);
    }

    private void loadSubtitles() {
        if (subtitles.isEmpty()) {
            try {
                final Response response = getDownloader().get(baseUrl
                        + PeertubeStreamLinkHandlerFactory.VIDEO_API_ENDPOINT
                        + getId() + "/captions");
                final JsonObject captionsJson = JsonParser.object().from(response.responseBody());
                final JsonArray captions = JsonUtils.getArray(captionsJson, "data");
                for (final Object c : captions) {
                    if (c instanceof JsonObject) {
                        final JsonObject caption = (JsonObject) c;
                        final String url = baseUrl + JsonUtils.getString(caption, "captionPath");
                        final String languageCode = JsonUtils.getString(caption, "language.id");
                        final String ext = url.substring(url.lastIndexOf(".") + 1);
                        final MediaFormat fmt = MediaFormat.getFromSuffix(ext);
                        if (fmt != null && !isNullOrEmpty(languageCode)) {
                            subtitles.add(new SubtitlesStream.Builder()
                                    .setContent(url, true)
                                    .setMediaFormat(fmt)
                                    .setLanguageCode(languageCode)
                                    .setAutoGenerated(false)
                                    .build());
                        }
                    }
                }
            } catch (final Exception e) {
                subtitlesException = new ParsingException("Could not get subtitles", e);
            }
        }
    }

    private void extractLiveVideoStreams() throws ParsingException {
        try {
            final JsonArray streamingPlaylists = json.getArray(STREAMING_PLAYLISTS);
            streamingPlaylists.stream()
                    .filter(JsonObject.class::isInstance)
                    .map(JsonObject.class::cast)
                    .map(stream -> new VideoStream.Builder()
                            .setId(String.valueOf(stream.getInt("id", -1)))
                            .setContent(stream.getString(PLAYLIST_URL, ""), true)
                            .setIsVideoOnly(false)
                            .setResolution("")
                            .setMediaFormat(MediaFormat.MPEG_4)
                            .setDeliveryMethod(DeliveryMethod.HLS)
                            .build())
                    // Don't use the containsSimilarStream method because it will always return
                    // false so if there are multiples HLS URLs returned, only the first will be
                    // extracted in this case.
                    .forEachOrdered(videoStreams::add);
        } catch (final Exception e) {
            throw new ParsingException("Could not get video streams", e);
        }
    }

    private void getStreams() throws ParsingException {
        // Progressive streams
        getStreamsFromArray(json.getArray(FILES), "");

        // HLS streams
        try {
            for (final JsonObject playlist : json.getArray(STREAMING_PLAYLISTS).stream()
                    .filter(JsonObject.class::isInstance)
                    .map(JsonObject.class::cast)
                    .collect(Collectors.toList())) {
                getStreamsFromArray(playlist.getArray(FILES), playlist.getString(PLAYLIST_URL));
            }
        } catch (final Exception e) {
            throw new ParsingException("Could not get streams", e);
        }
    }

    private void getStreamsFromArray(@Nonnull final JsonArray streams,
                                     final String playlistUrl) throws ParsingException {
        try {
            /*
            Starting with version 3.4.0 of PeerTube, the HLS playlist of stream resolutions
            contains the UUID of the streams, so we can't use the same method to get the URL of
            the HLS playlist without fetching the master playlist.
            These UUIDs are the same as the ones returned into the fileUrl and fileDownloadUrl
            strings.
            */
            final boolean isInstanceUsingRandomUuidsForHlsStreams = !isNullOrEmpty(playlistUrl)
                    && playlistUrl.endsWith("-master.m3u8");

            for (final JsonObject stream : streams.stream()
                    .filter(JsonObject.class::isInstance)
                    .map(JsonObject.class::cast)
                    .collect(Collectors.toList())) {

                // Extract stream version of streams first
                final String url = JsonUtils.getString(stream,
                        stream.has(FILE_URL) ? FILE_URL : FILE_DOWNLOAD_URL);
                if (isNullOrEmpty(url)) {
                    // Not a valid stream URL
                    return;
                }

                final String resolution = JsonUtils.getString(stream, "resolution.label");
                final String idSuffix = stream.has(FILE_URL) ? FILE_URL : FILE_DOWNLOAD_URL;

                if (resolution.toLowerCase().contains("audio")) {
                    // An audio stream
                    addNewAudioStream(stream, isInstanceUsingRandomUuidsForHlsStreams, resolution,
                            idSuffix, url, playlistUrl);
                } else {
                    // A video stream
                    addNewVideoStream(stream, isInstanceUsingRandomUuidsForHlsStreams, resolution,
                            idSuffix, url, playlistUrl);
                }
            }
        } catch (final Exception e) {
            throw new ParsingException("Could not get streams from array", e);
        }
    }

    @Nonnull
    private String getHlsPlaylistUrlFromFragmentedFileUrl(
            @Nonnull final JsonObject streamJsonObject,
            @Nonnull final String idSuffix,
            @Nonnull final String format,
            @Nonnull final String url) throws ParsingException {
        final String streamUrl = FILE_DOWNLOAD_URL.equals(idSuffix)
                ? JsonUtils.getString(streamJsonObject, FILE_URL)
                : url;
        return streamUrl.replace("-fragmented." + format, ".m3u8");
    }

    @Nonnull
    private String getHlsPlaylistUrlFromMasterPlaylist(@Nonnull final JsonObject streamJsonObject,
                                                       @Nonnull final String playlistUrl)
            throws ParsingException {
        return playlistUrl.replace("master", JsonUtils.getNumber(streamJsonObject,
                RESOLUTION_ID).toString());
    }

    private void addNewAudioStream(@Nonnull final JsonObject streamJsonObject,
                                   final boolean isInstanceUsingRandomUuidsForHlsStreams,
                                   @Nonnull final String resolution,
                                   @Nonnull final String idSuffix,
                                   @Nonnull final String url,
                                   @Nullable final String playlistUrl) throws ParsingException {
        final String extension = url.substring(url.lastIndexOf(".") + 1);
        final MediaFormat format = MediaFormat.getFromSuffix(extension);
        final String id = resolution + "-" + extension;

        // Add progressive HTTP streams first
        audioStreams.add(new AudioStream.Builder()
                .setId(id + "-" + idSuffix + "-" + DeliveryMethod.PROGRESSIVE_HTTP)
                .setContent(url, true)
                .setMediaFormat(format)
                .setAverageBitrate(UNKNOWN_BITRATE)
                .build());

        // Then add HLS streams
        if (!isNullOrEmpty(playlistUrl)) {
            final String hlsStreamUrl;
            if (isInstanceUsingRandomUuidsForHlsStreams) {
                hlsStreamUrl = getHlsPlaylistUrlFromFragmentedFileUrl(streamJsonObject, idSuffix,
                        extension, url);

            } else {
                hlsStreamUrl = getHlsPlaylistUrlFromMasterPlaylist(streamJsonObject, playlistUrl);
            }
            final AudioStream audioStream = new AudioStream.Builder()
                    .setId(id + "-" + DeliveryMethod.HLS)
                    .setContent(hlsStreamUrl, true)
                    .setDeliveryMethod(DeliveryMethod.HLS)
                    .setMediaFormat(format)
                    .setAverageBitrate(UNKNOWN_BITRATE)
                    .setManifestUrl(playlistUrl)
                    .build();
            if (!Stream.containSimilarStream(audioStream, audioStreams)) {
                audioStreams.add(audioStream);
            }
        }

        // Finally, add torrent URLs
        final String torrentUrl = JsonUtils.getString(streamJsonObject, "torrentUrl");
        if (!isNullOrEmpty(torrentUrl)) {
            audioStreams.add(new AudioStream.Builder()
                    .setId(id + "-" + idSuffix + "-" + DeliveryMethod.TORRENT)
                    .setContent(torrentUrl, true)
                    .setDeliveryMethod(DeliveryMethod.TORRENT)
                    .setMediaFormat(format)
                    .setAverageBitrate(UNKNOWN_BITRATE)
                    .build());
        }
    }

    private void addNewVideoStream(@Nonnull final JsonObject streamJsonObject,
                                   final boolean isInstanceUsingRandomUuidsForHlsStreams,
                                   @Nonnull final String resolution,
                                   @Nonnull final String idSuffix,
                                   @Nonnull final String url,
                                   @Nullable final String playlistUrl) throws ParsingException {
        final String extension = url.substring(url.lastIndexOf(".") + 1);
        final MediaFormat format = MediaFormat.getFromSuffix(extension);
        final String id = resolution + "-" + extension;

        // Add progressive HTTP streams first
        videoStreams.add(new VideoStream.Builder()
                .setId(id + "-" + idSuffix + "-" + DeliveryMethod.PROGRESSIVE_HTTP)
                .setContent(url, true)
                .setIsVideoOnly(false)
                .setResolution(resolution)
                .setMediaFormat(format)
                .build());

        // Then add HLS streams
        if (!isNullOrEmpty(playlistUrl)) {
            final String hlsStreamUrl = isInstanceUsingRandomUuidsForHlsStreams
                    ? getHlsPlaylistUrlFromFragmentedFileUrl(streamJsonObject, idSuffix, extension,
                            url)
                    : getHlsPlaylistUrlFromMasterPlaylist(streamJsonObject, playlistUrl);

            final VideoStream videoStream = new VideoStream.Builder()
                    .setId(id + "-" + DeliveryMethod.HLS)
                    .setContent(hlsStreamUrl, true)
                    .setIsVideoOnly(false)
                    .setDeliveryMethod(DeliveryMethod.HLS)
                    .setResolution(resolution)
                    .setMediaFormat(format)
                    .setManifestUrl(playlistUrl)
                    .build();
            if (!Stream.containSimilarStream(videoStream, videoStreams)) {
                videoStreams.add(videoStream);
            }
        }

        // Add finally torrent URLs
        final String torrentUrl = JsonUtils.getString(streamJsonObject, "torrentUrl");
        if (!isNullOrEmpty(torrentUrl)) {
            videoStreams.add(new VideoStream.Builder()
                    .setId(id + "-" + idSuffix + "-" + DeliveryMethod.TORRENT)
                    .setContent(torrentUrl, true)
                    .setIsVideoOnly(false)
                    .setDeliveryMethod(DeliveryMethod.TORRENT)
                    .setResolution(resolution)
                    .setMediaFormat(format)
                    .build());
        }
    }

    /**
     * Fetch content from a sub-API of the video.
     * @param subPath the API subpath after the video id,
     *                e.g. "storyboards" for "/api/v1/videos/{id}/storyboards"
     * @return the {@link JsonObject} of the sub-API or null if the API does not exist
     * which is the case if the instance has an outdated PeerTube version.
     * @throws ParsingException if the API response could not be parsed to a {@link JsonObject}
     * @throws IOException if the API response could not be fetched
     * @throws ReCaptchaException if the API response is a reCaptcha
     */
    @Nullable
    private JsonObject fetchSubApiContent(@Nonnull final String subPath)
            throws ParsingException, IOException, ReCaptchaException {
        final String apiUrl = baseUrl + PeertubeStreamLinkHandlerFactory.VIDEO_API_ENDPOINT
                + getId() + "/" + subPath;
        final Response response = getDownloader().get(apiUrl);
        if (response == null) {
            throw new ParsingException("Could not get segments from API.");
        }
        if (response.responseCode() == 400) {
            // Chapter or segments support was added with PeerTube v6.0.0
            // This instance does not support it yet.
            return null;
        }
        if (response.responseCode() != 200) {
            throw new ParsingException("Could not get segments from API. Response code: "
                    + response.responseCode());
        }
        try {
            return JsonParser.object().from(response.responseBody());
        } catch (final JsonParserException e) {
            throw new ParsingException("Could not parse json data for segments", e);
        }
    }

    @Nonnull
    @Override
    public String getName() throws ParsingException {
        return JsonUtils.getString(json, "name");
    }

    @Nonnull
    @Override
    public String getHost() throws ParsingException {
        return JsonUtils.getString(json, ACCOUNT_HOST);
    }

    @Nonnull
    @Override
    public Privacy getPrivacy() {
        switch (json.getObject("privacy").getInt("id")) {
            case 1:
                return Privacy.PUBLIC;
            case 2:
                return Privacy.UNLISTED;
            case 3:
                return Privacy.PRIVATE;
            case 4:
                return Privacy.INTERNAL;
            default:
                return Privacy.OTHER;
        }
    }

    @Nonnull
    @Override
    public String getCategory() throws ParsingException {
        return JsonUtils.getString(json, "category.label");
    }

    @Nonnull
    @Override
    public String getLicence() throws ParsingException {
        return JsonUtils.getString(json, "licence.label");
    }

    @Override
    public Locale getLanguageInfo() {
        try {
            return new Locale(JsonUtils.getString(json, "language.id"));
        } catch (final ParsingException e) {
            return null;
        }
    }
}
