org.schabi.newpipe.extractor.services.youtube.YoutubeStreamExtractor.java Source code

Java tutorial

Introduction

Here is the source code for org.schabi.newpipe.extractor.services.youtube.YoutubeStreamExtractor.java

Source

package org.schabi.newpipe.extractor.services.youtube;

import org.json.JSONException;
import org.json.JSONObject;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.mozilla.javascript.Context;
import org.mozilla.javascript.Function;
import org.mozilla.javascript.ScriptableObject;
import org.schabi.newpipe.extractor.AudioStream;
import org.schabi.newpipe.extractor.ExtractionException;
import org.schabi.newpipe.extractor.Downloader;
import org.schabi.newpipe.extractor.Parser;
import org.schabi.newpipe.extractor.ParsingException;
import org.schabi.newpipe.extractor.StreamInfo;
import org.schabi.newpipe.extractor.StreamPreviewInfo;
import org.schabi.newpipe.extractor.StreamUrlIdHandler;
import org.schabi.newpipe.extractor.StreamExtractor;
import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.VideoStream;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Vector;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Created by Christian Schabesberger on 06.08.15.
 *
 * Copyright (C) Christian Schabesberger 2015 <chris.schabesberger@mailbox.org>
 * YoutubeStreamExtractor.java is part of NewPipe.
 *
 * NewPipe is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * NewPipe is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with NewPipe.  If not, see <http://www.gnu.org/licenses/>.
 */

public class YoutubeStreamExtractor extends StreamExtractor {

    // exceptions

    public class DecryptException extends ParsingException {
        DecryptException(String message, Throwable cause) {
            super(message, cause);
        }
    }

    // special content not available exceptions

    public class GemaException extends ContentNotAvailableException {
        GemaException(String message) {
            super(message);
        }
    }

    public class LiveStreamException extends ContentNotAvailableException {
        LiveStreamException(String message) {
            super(message);
        }
    }

    // ----------------

    // Sometimes if the html page of youtube is already downloaded, youtube web page will internally
    // download the /get_video_info page. Since a certain date dashmpd url is only available over
    // this /get_video_info page, so we always need to download this one to.
    // %%video_id%% will be replaced by the actual video id
    // $$el_type$$ will be replaced by the actual el_type (se the declarations below)
    private static final String GET_VIDEO_INFO_URL = "https://www.youtube.com/get_video_info?video_id=%%video_id%%$$el_type$$&ps=default&eurl=&gl=US&hl=en";
    // eltype is nececeary for the url aboth
    private static final String EL_INFO = "el=info";

    public enum ItagType {
        AUDIO, VIDEO, VIDEO_ONLY
    }

    private static class ItagItem {
        public ItagItem(int id, ItagType type, MediaFormat format, String res, int fps) {
            this.id = id;
            this.itagType = type;
            this.mediaFormatId = format.id;
            this.resolutionString = res;
            this.fps = fps;
        }

        public ItagItem(int id, ItagType type, MediaFormat format, int samplingRate, int bandWidth) {
            this.id = id;
            this.itagType = type;
            this.mediaFormatId = format.id;
            this.samplingRate = samplingRate;
            this.bandWidth = bandWidth;
        }

        public int id;
        public ItagType itagType;
        public int mediaFormatId;
        public String resolutionString = null;
        public int fps = -1;
        public int samplingRate = -1;
        public int bandWidth = -1;
    }

    private static final ItagItem[] itagList = {
            // video streams
            //           id, ItagType,       MediaFormat,    Resolution, fps
            new ItagItem(17, ItagType.VIDEO, MediaFormat.v3GPP, "144p", 12),
            new ItagItem(18, ItagType.VIDEO, MediaFormat.MPEG_4, "360p", 24),
            new ItagItem(22, ItagType.VIDEO, MediaFormat.MPEG_4, "720p", 24),
            new ItagItem(36, ItagType.VIDEO, MediaFormat.v3GPP, "240p", 24),
            new ItagItem(37, ItagType.VIDEO, MediaFormat.MPEG_4, "1080p", 24),
            new ItagItem(38, ItagType.VIDEO, MediaFormat.MPEG_4, "1080p", 24),
            new ItagItem(43, ItagType.VIDEO, MediaFormat.WEBM, "360p", 24),
            new ItagItem(44, ItagType.VIDEO, MediaFormat.WEBM, "480p", 24),
            new ItagItem(45, ItagType.VIDEO, MediaFormat.WEBM, "720p", 24),
            new ItagItem(46, ItagType.VIDEO, MediaFormat.WEBM, "1080p", 24),
            // audio streams
            //           id, ItagType,       MediaFormat,    samplingR, bandwidth
            new ItagItem(249, ItagType.AUDIO, MediaFormat.WEBMA, 0, 0), // bandwith/samplingR 0 because not known
            new ItagItem(250, ItagType.AUDIO, MediaFormat.WEBMA, 0, 0),
            new ItagItem(171, ItagType.AUDIO, MediaFormat.WEBMA, 0, 0),
            new ItagItem(140, ItagType.AUDIO, MediaFormat.M4A, 0, 0),
            new ItagItem(251, ItagType.AUDIO, MediaFormat.WEBMA, 0, 0),
            // video only streams
            new ItagItem(160, ItagType.VIDEO_ONLY, MediaFormat.MPEG_4, "144p", 24),
            new ItagItem(133, ItagType.VIDEO_ONLY, MediaFormat.MPEG_4, "240p", 24),
            new ItagItem(134, ItagType.VIDEO_ONLY, MediaFormat.MPEG_4, "360p", 24),
            new ItagItem(135, ItagType.VIDEO_ONLY, MediaFormat.MPEG_4, "480p", 24),
            new ItagItem(136, ItagType.VIDEO_ONLY, MediaFormat.MPEG_4, "720p", 24),
            new ItagItem(137, ItagType.VIDEO_ONLY, MediaFormat.MPEG_4, "1080p", 24), };

    /**These lists only contain itag formats that are supported by the common Android Video player.
     However if you are looking for a list showing all itag formats, look at
     https://github.com/rg3/youtube-dl/issues/1687 */

    public static boolean itagIsSupported(int itag) {
        for (ItagItem item : itagList) {
            if (itag == item.id) {
                return true;
            }
        }
        return false;
    }

    public static ItagItem getItagItem(int itag) throws ParsingException {
        for (ItagItem item : itagList) {
            if (itag == item.id) {
                return item;
            }
        }
        throw new ParsingException("itag=" + Integer.toString(itag) + " not supported");
    }

    private static final String TAG = YoutubeStreamExtractor.class.toString();
    private final Document doc;
    private JSONObject playerArgs;
    private boolean isAgeRestricted;
    private Map<String, String> videoInfoPage;

    // static values
    private static final String DECRYPTION_FUNC_NAME = "decrypt";

    // cached values
    private static volatile String decryptionCode = "";

    StreamUrlIdHandler urlidhandler = new YoutubeStreamUrlIdHandler();
    String pageUrl = "";

    private Downloader downloader;

    public YoutubeStreamExtractor(String pageUrl, Downloader dl, int serviceId)
            throws ExtractionException, IOException {
        super(pageUrl, dl, serviceId);
        //most common videoInfo fields are now set in our superclass, for all services
        downloader = dl;
        this.pageUrl = pageUrl;
        String pageContent = downloader.download(urlidhandler.cleanUrl(pageUrl));
        doc = Jsoup.parse(pageContent, pageUrl);
        JSONObject ytPlayerConfig;
        String playerUrl;

        // Check if the video is age restricted
        if (pageContent.contains("<meta property=\"og:restrictions:age")) {
            String videoInfoUrl = GET_VIDEO_INFO_URL.replace("%%video_id%%", urlidhandler.getVideoId(pageUrl))
                    .replace("$$el_type$$", "&" + EL_INFO);
            String videoInfoPageString = downloader.download(videoInfoUrl);
            videoInfoPage = Parser.compatParseMap(videoInfoPageString);
            playerUrl = getPlayerUrlFromRestrictedVideo(pageUrl);
            isAgeRestricted = true;
        } else {
            ytPlayerConfig = getPlayerConfig(pageContent);
            playerArgs = getPlayerArgs(ytPlayerConfig);
            playerUrl = getPlayerUrl(ytPlayerConfig);
            isAgeRestricted = false;
        }

        if (decryptionCode.isEmpty()) {
            decryptionCode = loadDecryptionCode(playerUrl);
        }
    }

    private JSONObject getPlayerConfig(String pageContent) throws ParsingException {
        try {
            String ytPlayerConfigRaw = Parser.matchGroup1("ytplayer.config\\s*=\\s*(\\{.*?\\});", pageContent);
            return new JSONObject(ytPlayerConfigRaw);
        } catch (Parser.RegexException e) {
            String errorReason = findErrorReason(doc);
            switch (errorReason) {
            case "GEMA":
                throw new GemaException(errorReason);
            case "":
                throw new ContentNotAvailableException("Content not available: player config empty", e);
            default:
                throw new ContentNotAvailableException("Content not available", e);
            }
        } catch (JSONException e) {
            throw new ParsingException("Could not parse yt player config", e);
        }
    }

    private JSONObject getPlayerArgs(JSONObject playerConfig) throws ParsingException {
        JSONObject playerArgs;

        //attempt to load the youtube js player JSON arguments
        boolean isLiveStream = false; //used to determine if this is a livestream or not
        try {
            playerArgs = playerConfig.getJSONObject("args");

            // check if we have a live stream. We need to filter it, since its not yet supported.
            if ((playerArgs.has("ps") && playerArgs.get("ps").toString().equals("live"))
                    || (playerArgs.get("url_encoded_fmt_stream_map").toString().isEmpty())) {
                isLiveStream = true;
            }
        } catch (JSONException e) {
            throw new ParsingException("Could not parse yt player config", e);
        }
        if (isLiveStream) {
            throw new LiveStreamException("This is a Life stream. Can't use those right now.");
        }

        return playerArgs;
    }

    private String getPlayerUrl(JSONObject playerConfig) throws ParsingException {
        try {
            // The Youtube service needs to be initialized by downloading the
            // js-Youtube-player. This is done in order to get the algorithm
            // for decrypting cryptic signatures inside certain stream urls.
            String playerUrl = "";

            JSONObject ytAssets = playerConfig.getJSONObject("assets");
            playerUrl = ytAssets.getString("js");

            if (playerUrl.startsWith("//")) {
                playerUrl = "https:" + playerUrl;
            }
            return playerUrl;
        } catch (JSONException e) {
            throw new ParsingException("Could not load decryption code for the Youtube service.", e);
        }
    }

    private String getPlayerUrlFromRestrictedVideo(String pageUrl) throws ParsingException {
        try {
            String playerUrl = "";
            String videoId = urlidhandler.getVideoId(pageUrl);
            String embedUrl = "https://www.youtube.com/embed/" + videoId;
            String embedPageContent = downloader.download(embedUrl);
            //todo: find out if this can be reapaced by Parser.matchGroup1()
            Pattern assetsPattern = Pattern.compile("\"assets\":.+?\"js\":\\s*(\"[^\"]+\")");
            Matcher patternMatcher = assetsPattern.matcher(embedPageContent);
            while (patternMatcher.find()) {
                playerUrl = patternMatcher.group(1);
            }
            playerUrl = playerUrl.replace("\\", "").replace("\"", "");

            if (playerUrl.startsWith("//")) {
                playerUrl = "https:" + playerUrl;
            }
            return playerUrl;
        } catch (IOException e) {
            throw new ParsingException("Could load decryption code form restricted video for the Youtube service.",
                    e);
        }
    }

    @Override
    public String getTitle() throws ParsingException {
        try {
            if (playerArgs == null) {
                return videoInfoPage.get("title");
            }
            //json player args method
            return playerArgs.getString("title");
        } catch (JSONException je) {//html <meta> method
            je.printStackTrace();
            System.err.println("failed to load title from JSON args; trying to extract it from HTML");
            try { // fall through to fall-back
                return doc.select("meta[name=title]").attr("content");
            } catch (Exception e) {
                throw new ParsingException("failed permanently to load title.", e);
            }
        }
    }

    // COMP 530 new method to get channel ID of the video
    @Override
    public String getChannelId() throws ParsingException {
        try {
            if (playerArgs == null) {
                //ucid is the channelID
                return videoInfoPage.get("ucid");
            }
            //json player args method
            return playerArgs.getString("ucid");
        } catch (JSONException je) {//html <meta> method
            je.printStackTrace();
            System.err.println("failed to load title from JSON args; trying to extract it from HTML");
            try { // fall through to fall-back
                  //if could not get channel ID from ucid, the alternate way to get the channel ID
                return doc.select("meta[itemprop=channelId]").attr("content");
            } catch (Exception e) {
                throw new ParsingException("failed permanently to load title.", e);
            }
        }
    }
    //COMP 530

    @Override
    public String getDescription() throws ParsingException {
        try {
            return doc.select("p[id=\"eow-description\"]").first().html();
        } catch (Exception e) {//todo: add fallback method <-- there is no ... as long as i know
            throw new ParsingException("failed to load description.", e);
        }
    }

    @Override
    public String getUploader() throws ParsingException {
        try {
            if (playerArgs == null) {
                return videoInfoPage.get("author");
            }
            //json player args method
            return playerArgs.getString("author");
        } catch (JSONException je) {
            je.printStackTrace();
            System.err.println("failed to load uploader name from JSON args; trying to extract it from HTML");
        }
        try {//fall through to fallback HTML method
            return doc.select("div.yt-user-info").first().text();
        } catch (Exception e) {
            throw new ParsingException("failed permanently to load uploader name.", e);
        }
    }

    @Override
    public int getLength() throws ParsingException {
        try {
            if (playerArgs == null) {
                return Integer.valueOf(videoInfoPage.get("length_seconds"));
            }
            return playerArgs.getInt("length_seconds");
        } catch (JSONException e) {//todo: find fallback method
            throw new ParsingException("failed to load video duration from JSON args", e);
        }
    }

    @Override
    public long getViewCount() throws ParsingException {
        try {
            String viewCountString = doc.select("meta[itemprop=interactionCount]").attr("content");
            return Long.parseLong(viewCountString);
        } catch (Exception e) {//todo: find fallback method
            throw new ParsingException("failed to get number of views", e);
        }
    }

    @Override
    public String getUploadDate() throws ParsingException {
        try {
            return doc.select("meta[itemprop=datePublished]").attr("content");
        } catch (Exception e) {//todo: add fallback method
            throw new ParsingException("failed to get upload date.", e);
        }
    }

    @Override
    public String getThumbnailUrl() throws ParsingException {
        //first attempt getting a small image version
        //in the html extracting part we try to get a thumbnail with a higher resolution
        // Try to get high resolution thumbnail if it fails use low res from the player instead
        try {
            return doc.select("link[itemprop=\"thumbnailUrl\"]").first().attr("abs:href");
        } catch (Exception e) {
            System.err.println("Could not find high res Thumbnail. Using low res instead");
        }
        try { //fall through to fallback
            return playerArgs.getString("thumbnail_url");
        } catch (JSONException je) {
            throw new ParsingException(
                    "failed to extract thumbnail URL from JSON args; trying to extract it from HTML", je);
        } catch (NullPointerException ne) {
            // Get from the video info page instead
            return videoInfoPage.get("thumbnail_url");
        }
    }

    @Override
    public String getUploaderThumbnailUrl() throws ParsingException {
        try {
            return doc.select("a[class*=\"yt-user-photo\"]").first().select("img").first().attr("abs:data-thumb");
        } catch (Exception e) {//todo: add fallback method
            throw new ParsingException("failed to get uploader thumbnail URL.", e);
        }
    }

    @Override
    public String getDashMpdUrl() throws ParsingException {
        /*
        try {
        String dashManifestUrl = videoInfoPage.get("dashmpd");
        if(!dashManifestUrl.contains("/signature/")) {
            String encryptedSig = Parser.matchGroup1("/s/([a-fA-F0-9\\.]+)", dashManifestUrl);
            String decryptedSig;
            
            decryptedSig = decryptSignature(encryptedSig, decryptionCode);
            dashManifestUrl = dashManifestUrl.replace("/s/" + encryptedSig, "/signature/" + decryptedSig);
        }
        return dashManifestUrl;
        } catch (Exception e) {
        throw new ParsingException(
                "Could not get \"dashmpd\" maybe VideoInfoPage is broken.", e);
        }
        */
        return "";
    }

    @Override
    public List<AudioStream> getAudioStreams() throws ParsingException {
        Vector<AudioStream> audioStreams = new Vector<>();
        try {
            String encodedUrlMap;
            // playerArgs could be null if the video is age restricted
            if (playerArgs == null) {
                encodedUrlMap = videoInfoPage.get("adaptive_fmts");
            } else {
                encodedUrlMap = playerArgs.getString("adaptive_fmts");
            }
            for (String url_data_str : encodedUrlMap.split(",")) {
                // This loop iterates through multiple streams, therefor tags
                // is related to one and the same stream at a time.
                Map<String, String> tags = Parser
                        .compatParseMap(org.jsoup.parser.Parser.unescapeEntities(url_data_str, true));

                int itag = Integer.parseInt(tags.get("itag"));

                if (itagIsSupported(itag)) {
                    ItagItem itagItem = getItagItem(itag);
                    if (itagItem.itagType == ItagType.AUDIO) {
                        String streamUrl = tags.get("url");
                        // if video has a signature: decrypt it and add it to the url
                        if (tags.get("s") != null) {
                            streamUrl = streamUrl + "&signature=" + decryptSignature(tags.get("s"), decryptionCode);
                        }

                        audioStreams.add(new AudioStream(streamUrl, itagItem.mediaFormatId, itagItem.bandWidth,
                                itagItem.samplingRate));
                    }
                }
            }
        } catch (Exception e) {
            throw new ParsingException("Could not get audiostreams", e);
        }
        return audioStreams;
    }

    @Override
    public List<VideoStream> getVideoStreams() throws ParsingException {
        Vector<VideoStream> videoStreams = new Vector<>();

        try {
            String encodedUrlMap;
            // playerArgs could be null if the video is age restricted
            if (playerArgs == null) {
                encodedUrlMap = videoInfoPage.get("url_encoded_fmt_stream_map");
            } else {
                encodedUrlMap = playerArgs.getString("url_encoded_fmt_stream_map");
            }
            for (String url_data_str : encodedUrlMap.split(",")) {
                try {
                    // This loop iterates through multiple streams, therefor tags
                    // is related to one and the same stream at a time.
                    Map<String, String> tags = Parser
                            .compatParseMap(org.jsoup.parser.Parser.unescapeEntities(url_data_str, true));

                    int itag = Integer.parseInt(tags.get("itag"));

                    if (itagIsSupported(itag)) {
                        ItagItem itagItem = getItagItem(itag);
                        if (itagItem.itagType == ItagType.VIDEO) {
                            String streamUrl = tags.get("url");
                            // if video has a signature: decrypt it and add it to the url
                            if (tags.get("s") != null) {
                                streamUrl = streamUrl + "&signature="
                                        + decryptSignature(tags.get("s"), decryptionCode);
                            }
                            videoStreams.add(
                                    new VideoStream(streamUrl, itagItem.mediaFormatId, itagItem.resolutionString));
                        }
                    }
                } catch (Exception e) {
                    //todo: dont log throw an error
                    System.err.println("Could not get Video stream.");
                    e.printStackTrace();
                }
            }

        } catch (Exception e) {
            throw new ParsingException("Failed to get video streams", e);
        }

        if (videoStreams.isEmpty()) {
            throw new ParsingException("Failed to get any video stream");
        }
        return videoStreams;
    }

    @Override
    public List<VideoStream> getVideoOnlyStreams() throws ParsingException {
        return null;
    }

    /**Attempts to parse (and return) the offset to start playing the video from.
     * @return the offset (in seconds), or 0 if no timestamp is found.*/
    @Override
    public int getTimeStamp() throws ParsingException {
        String timeStamp;
        try {
            timeStamp = Parser.matchGroup1("((#|&|\\?)t=\\d{0,3}h?\\d{0,3}m?\\d{1,3}s?)", pageUrl);
        } catch (Parser.RegexException e) {
            // catch this instantly since an url does not necessarily have to have a time stamp

            // -2 because well the testing system will then know its the regex that failed :/
            // not good i know
            return -2;
        }

        if (!timeStamp.isEmpty()) {
            try {
                String secondsString = "";
                String minutesString = "";
                String hoursString = "";
                try {
                    secondsString = Parser.matchGroup1("(\\d{1,3})s", timeStamp);
                    minutesString = Parser.matchGroup1("(\\d{1,3})m", timeStamp);
                    hoursString = Parser.matchGroup1("(\\d{1,3})h", timeStamp);
                } catch (Exception e) {
                    //it could be that time is given in another method
                    if (secondsString.isEmpty() //if nothing was got,
                            && minutesString.isEmpty()//treat as unlabelled seconds
                            && hoursString.isEmpty()) {
                        secondsString = Parser.matchGroup1("t=(\\d{1,3})", timeStamp);
                    }
                }

                int seconds = secondsString.isEmpty() ? 0 : Integer.parseInt(secondsString);
                int minutes = minutesString.isEmpty() ? 0 : Integer.parseInt(minutesString);
                int hours = hoursString.isEmpty() ? 0 : Integer.parseInt(hoursString);

                //don't trust BODMAS!
                return seconds + (60 * minutes) + (3600 * hours);
                //Log.d(TAG, "derived timestamp value:"+ret);
                //the ordering varies internationally
            } catch (ParsingException e) {
                throw new ParsingException("Could not get timestamp.", e);
            }
        } else {
            return 0;
        }
    }

    @Override
    public int getAgeLimit() throws ParsingException {
        if (!isAgeRestricted) {
            return 0;
        }
        try {
            return Integer.valueOf(doc.head().getElementsByAttributeValue("property", "og:restrictions:age")
                    .attr("content").replace("+", ""));
        } catch (Exception e) {
            throw new ParsingException("Could not get age restriction");
        }
    }

    @Override
    public String getAverageRating() throws ParsingException {
        try {
            if (playerArgs == null) {
                return videoInfoPage.get("avg_rating");
            }
            return playerArgs.getString("avg_rating");
        } catch (JSONException e) {
            throw new ParsingException("Could not get Average rating", e);
        }
    }

    @Override
    public int getLikeCount() throws ParsingException {
        String likesString = "";
        try {

            Element button = doc.select("button.like-button-renderer-like-button").first();
            try {
                likesString = button.select("span.yt-uix-button-content").first().text();
            } catch (NullPointerException e) {
                //if this ckicks in our button has no content and thefore likes/dislikes are disabled
                return -1;
            }
            return Integer.parseInt(likesString.replaceAll("[^\\d]", ""));
        } catch (NumberFormatException nfe) {
            throw new ParsingException("failed to parse likesString \"" + likesString + "\" as integers", nfe);
        } catch (Exception e) {
            throw new ParsingException("Could not get like count", e);
        }
    }

    @Override
    public int getDislikeCount() throws ParsingException {
        String dislikesString = "";
        try {
            Element button = doc.select("button.like-button-renderer-dislike-button").first();
            try {
                dislikesString = button.select("span.yt-uix-button-content").first().text();
            } catch (NullPointerException e) {
                //if this kicks in our button has no content and therefore likes/dislikes are disabled
                return -1;
            }
            return Integer.parseInt(dislikesString.replaceAll("[^\\d]", ""));
        } catch (NumberFormatException nfe) {
            throw new ParsingException("failed to parse dislikesString \"" + dislikesString + "\" as integers",
                    nfe);
        } catch (Exception e) {
            throw new ParsingException("Could not get dislike count", e);
        }
    }

    @Override
    public StreamPreviewInfo getNextVideo() throws ParsingException {
        try {
            return extractVideoPreviewInfo(
                    doc.select("div[class=\"watch-sidebar-section\"]").first().select("li").first());
        } catch (Exception e) {
            throw new ParsingException("Could not get next video", e);
        }
    }

    @Override
    public Vector<StreamPreviewInfo> getRelatedVideos() throws ParsingException {
        try {
            Vector<StreamPreviewInfo> relatedVideos = new Vector<>();
            for (Element li : doc.select("ul[id=\"watch-related\"]").first().children()) {
                // first check if we have a playlist. If so leave them out
                if (li.select("a[class*=\"content-link\"]").first() != null) {
                    relatedVideos.add(extractVideoPreviewInfo(li));
                }
            }
            return relatedVideos;
        } catch (Exception e) {
            throw new ParsingException("Could not get related videos", e);
        }
    }

    @Override
    public StreamUrlIdHandler getUrlIdConverter() {
        return new YoutubeStreamUrlIdHandler();
    }

    @Override
    public String getPageUrl() {
        return pageUrl;
    }

    @Override
    public StreamInfo.StreamType getStreamType() throws ParsingException {
        //todo: if implementing livestream support this value should be generated dynamically
        return StreamInfo.StreamType.VIDEO_STREAM;
    }

    /**Provides information about links to other videos on the video page, such as related videos.
     * This is encapsulated in a StreamPreviewInfo object,
     * which is a subset of the fields in a full StreamInfo.*/
    private StreamPreviewInfo extractVideoPreviewInfo(Element li) throws ParsingException {
        StreamPreviewInfo info = new StreamPreviewInfo();

        try {
            info.webpage_url = li.select("a.content-link").first().attr("abs:href");

            info.id = Parser.matchGroup1("v=([0-9a-zA-Z-]*)", info.webpage_url);

            //todo: check NullPointerException causing
            info.title = li.select("span.title").first().text();
            //this page causes the NullPointerException, after finding it by searching for "tjvg":
            //https://www.youtube.com/watch?v=Uqg0aEhLFAg

            //this line is unused
            //String views = li.select("span.view-count").first().text();

            //Log.i(TAG, "title:"+info.title);
            //Log.i(TAG, "view count:"+views);

            try {
                info.view_count = Long
                        .parseLong(li.select("span.view-count").first().text().replaceAll("[^\\d]", ""));
            } catch (Exception e) {//related videos sometimes have no view count
                info.view_count = 0;
            }
            info.uploader = li.select("span.g-hovercard").first().text();

            info.duration = YoutubeParsingHelper.parseDurationString(li.select("span.video-time").first().text());

            Element img = li.select("img").first();
            info.thumbnail_url = img.attr("abs:src");
            // Sometimes youtube sends links to gif files which somehow seem to not exist
            // anymore. Items with such gif also offer a secondary image source. So we are going
            // to use that if we caught such an item.
            if (info.thumbnail_url.contains(".gif")) {
                info.thumbnail_url = img.attr("data-thumb");
            }
            if (info.thumbnail_url.startsWith("//")) {
                info.thumbnail_url = "https:" + info.thumbnail_url;
            }
        } catch (Exception e) {
            throw new ParsingException("Could not get video preview info", e);
        }
        return info;
    }

    private String loadDecryptionCode(String playerUrl) throws DecryptException {
        String decryptionFuncName;
        String decryptionFunc;
        String helperObjectName;
        String helperObject;
        String callerFunc = "function " + DECRYPTION_FUNC_NAME + "(a){return %%(a);}";
        String decryptionCode;

        try {
            String playerCode = downloader.download(playerUrl);

            decryptionFuncName = Parser.matchGroup1("\\.sig\\|\\|([a-zA-Z0-9$]+)\\(", playerCode);

            String functionPattern = "(" + decryptionFuncName.replace("$", "\\$")
                    + "=function\\([a-zA-Z0-9_]*\\)\\{.+?\\})";
            decryptionFunc = "var " + Parser.matchGroup1(functionPattern, playerCode) + ";";

            helperObjectName = Parser.matchGroup1(";([A-Za-z0-9_\\$]{2})\\...\\(", decryptionFunc);

            String helperPattern = "(var " + helperObjectName.replace("$", "\\$") + "=\\{.+?\\}\\};)";
            helperObject = Parser.matchGroup1(helperPattern, playerCode);

            callerFunc = callerFunc.replace("%%", decryptionFuncName);
            decryptionCode = helperObject + decryptionFunc + callerFunc;
        } catch (IOException ioe) {
            throw new DecryptException("Could not load decrypt function", ioe);
        } catch (Exception e) {
            throw new DecryptException("Could not parse decrypt function ", e);
        }

        return decryptionCode;
    }

    private String decryptSignature(String encryptedSig, String decryptionCode) throws DecryptException {
        Context context = Context.enter();
        context.setOptimizationLevel(-1);
        Object result = null;
        try {
            ScriptableObject scope = context.initStandardObjects();
            context.evaluateString(scope, decryptionCode, "decryptionCode", 1, null);
            Function decryptionFunc = (Function) scope.get("decrypt", scope);
            result = decryptionFunc.call(context, scope, scope, new Object[] { encryptedSig });
        } catch (Exception e) {
            throw new DecryptException("could not get decrypt signature", e);
        } finally {
            Context.exit();
        }
        return result == null ? "" : result.toString();
    }

    private String findErrorReason(Document doc) {
        String errorMessage = doc.select("h1[id=\"unavailable-message\"]").first().text();
        if (errorMessage.contains("GEMA")) {
            // Gema sometimes blocks youtube music content in germany:
            // https://www.gema.de/en/
            // Detailed description:
            // https://en.wikipedia.org/wiki/GEMA_%28German_organization%29
            return "GEMA";
        }
        return "";
    }
}