import groovy.json.JsonSlurper import groovy.json.StringEscapeUtils import org.serviio.library.metadata.MediaFileType import org.serviio.library.online.* /** * YouTube.com content URL extractor plugin. * * It uses YouTube API v3 for retrieving playlists. * * @see http://en.wikipedia.org/wiki/Youtube#Quality_and_codecs * * @author Petr Nejedly * @modified drJeckyll * @modified Pavlo Kudlay * tested on serviio 1.9.1 * 30.07.2021 guest changes by it.x: "/get_video_info" issue; lines <106..133>; tested on v.2.1.0-0038 */ class YouTube extends WebResourceUrlExtractor { final VALID_RESOURCE_URL = '^https?://www.googleapis.com/youtube/.*$' final PLAYER_REGEX = '"assets":.+?"js":\\s*"([^"]+)"' final user_agent = "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident/6.0)" /* Listed in order of quality */ final availableFormats = ['37', '46', '22', '45', '35', '34', '18', '44', '43', '6', '5'] String getExtractorName() { return getClass().getName() } boolean extractorMatches(URL feedUrl) { return feedUrl ==~ VALID_RESOURCE_URL } public int getVersion() { return 5; } static class FormatDetails { public String itag public String url public Date expiry FormatDetails(String itag, String url, Date expiry) { this.itag = itag this.url = url this.expiry = expiry } } @Override WebResourceContainer extractItems(URL resourceUrl, int maxItemsToRetrieve) { final api_key = "AIzaSyCbFQoCv41IVcHal6bbsDuKuSqGVE243q0" // this is Serviio API key, don't use it for anything else please, they are easy to get if (maxItemsToRetrieve == -1) maxItemsToRetrieve = 50 // Handle channel name urls if (resourceUrl.toString().contains("channels")) { def channelUrl = new URL(resourceUrl.toString() + "&part=contentDetails" + "&key=" + api_key) def channeljson = new JsonSlurper().parseText(openURL(channelUrl, user_agent)) def uploadPlaylist = channeljson.items[0].contentDetails.relatedPlaylists.uploads resourceUrl = new URL("https://www.googleapis.com/youtube/v3/playlistItems?playlistId=$uploadPlaylist") } def apiUrl = new URL(resourceUrl.toString() + "&part=snippet" + "&maxResults=" + maxItemsToRetrieve + "&type=video" + "&key=" + api_key) def json = new JsonSlurper().parseText(openURL(apiUrl, user_agent)) def items = [] // for long playlists it may take a while to update def i = 0 boolean done = false while (json.pageInfo.totalResults > i && !done) { json.items.each() { i++ if (it.snippet.title != "Deleted video" && it.snippet.title != "Private video") { items.add(new WebResourceItem(title: it.snippet.title, additionalInfo: ['videoId': resourceUrl.toString().contains("videos") ? it.id : resourceUrl.toString().contains("search") ? it.id.videoId : it.snippet.resourceId.videoId, 'thumb' : it.snippet.thumbnails.high.url])) } } done = items.size() >= maxItemsToRetrieve if (json.nextPageToken != null && !done) { // repeat with supplied token apiUrl = new URL(resourceUrl.toString() + "&part=snippet" + "&maxResults=" + maxItemsToRetrieve + "&type=video" + "&key=" + api_key + "&pageToken=" + json.nextPageToken) json = new JsonSlurper().parseText(openURL(apiUrl, user_agent)) } } def containerThumbnailUrl = items?.find { it -> it.additionalInfo['thumb'] != null }?.additionalInfo['thumb'] return new WebResourceContainer(items: items, thumbnailUrl: containerThumbnailUrl) } @Override protected ContentURLContainer extractUrl(WebResourceItem wrItem, PreferredQuality requestedQuality) { def contentUrl def expiryDate def expiresImmediately def cacheKey def videoId = wrItem.additionalInfo['videoId'] def thumbnailUrl = wrItem.additionalInfo['thumb'] /////////////// for (elType in ['&el=embedded', '&el=detailpage', '&el=vevo', '']) { // ver.1 blocked by Google ca. 01.05.2021 def videoInfoUrl = "https://www.youtube.com/get_video_info?&video_id=$videoId$elType&ps=default&eurl=&gl=US&hl=en" // ver.2 blocked by Google 19.06.2021 def videoInfoUrl = "https://www.youtube.com/get_video_info?html5=1&video_id=$videoId$elType&ps=default&eurl=&gl=US&hl=en" // ver.3 blocked by Google 30.07.2021 def videoInfoUrl = "https://www.youtube.com/get_video_info?video_id=$videoId&eurl=https%3A%2F%2Fyoutube.googleapis.com%2Fv%2F$videoId&html5=1&c=TVHTML5&cver=6.20180913$elType&ps=default&eurl=&gl=US&hl=en" def videoInfoUrl = "https://youtubei.googleapis.com/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" def TmpPost = new URL("https://youtubei.googleapis.com/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8").openConnection(); def TmpBody ='{"context": {"client": {"hl": "en", "clientName": "WEB", "clientVersion": "2.20210721.00.00", "mainAppWebInfo": {"graftUrl": "/watch?v='+"$videoId"+'"}}}, "videoId": "'+"$videoId"+'"}' TmpPost.setRequestMethod("POST") TmpPost.setDoOutput(true) TmpPost.setRequestProperty("Content-Type", "application/json") TmpPost.getOutputStream().write(TmpBody.getBytes("UTF-8")) //org def videoInfoWebPage = new URL(videoInfoUrl).getText() def videoInfoWebPage = TmpPost.getInputStream().getText() //org def parameters = [:] //org videoInfoWebPage.split('&').each { item -> addParameter(item, parameters, '=') } def jsonSlurper2 = new JsonSlurper() def parameters = jsonSlurper2.parseText(videoInfoWebPage) // org def playerResponse = parameters['player_response'] def playerResponse = parameters['responseContext'] if(playerResponse) { //org def pr = new JsonSlurper().parseText(URLDecoder.decode(playerResponse, 'UTF-8')) //org def streamingData = pr['streamingData'] def streamingData = parameters['streamingData'] if(!streamingData) { continue } /////////////// List streamingFormats = [] List formats = streamingData['formats'] List adaptiveFormats = streamingData['adaptiveFormats'] if(formats && !formats.empty) { streamingFormats.addAll(formats) } if(adaptiveFormats && !adaptiveFormats.empty) { streamingFormats.addAll(adaptiveFormats) } String videoPageHtml = openURL(new URL("https://www.youtube.com/embed/$videoId"), user_agent) Map allFormatUrls = [:] streamingFormats.each { it -> processStreamData2(videoId, it, videoPageHtml, allFormatUrls) } Map formatUrlMap = new LinkedHashMap() if(allFormatUrls.isEmpty()) { // signature probably failed } else { FormatDetails selectedFormat = null if (requestedQuality == PreferredQuality.HIGH) { // best quality, get the first from the list sortAvailableFormatUrls2(availableFormats, allFormatUrls, formatUrlMap) selectedFormat = formatUrlMap.entrySet().toList().head().getValue() } else if (requestedQuality == PreferredQuality.MEDIUM) { // work with subset of available formats, starting at the position of format 35 and then take the best quality from there sortAvailableFormatUrls2(availableFormats.getAt(4..availableFormats.size - 1), allFormatUrls, formatUrlMap) selectedFormat = formatUrlMap.entrySet().toList().head().getValue() } else { // worst quality, take the last url sortAvailableFormatUrls2(availableFormats, allFormatUrls, formatUrlMap) selectedFormat = formatUrlMap.entrySet().toList().last().getValue() } if (selectedFormat != null) { contentUrl = selectedFormat.url cacheKey = getCacheKey(videoId, selectedFormat.itag) if (selectedFormat.expiry) { expiresImmediately = false expiryDate = selectedFormat.expiry } else { expiresImmediately = true } } } break } } if(contentUrl) { return new ContentURLContainer(fileType: MediaFileType.VIDEO, contentUrl: contentUrl, thumbnailUrl: thumbnailUrl, expiresOn: expiryDate, expiresImmediately: expiresImmediately, cacheKey: cacheKey) } else { // could not work out the stream url return null } } def addParameter(parameterString, parameters, separator) { def values = parameterString.split(separator) if (values.length == 2) { parameters.put(values[0], values[1]) } } def processStreamData2(String videoId, Map format, String videoPageHtml, Map streamFormats) { def url = format['url'] String streamUrl = null if(url) { streamUrl = URLDecoder.decode(url, 'UTF-8') } else { def cipher = format['cipher'] if (!cipher) { return } def urlData = parseQueryString(cipher) url = urlData['url'] if (!url) return streamUrl = URLDecoder.decode(url, 'UTF-8') String signature = urlData['sig'] String s = urlData['s'] if (signature) { streamUrl = streamUrl + "&signature=" + signature } else if (s) { String playerUrlJson = getPlayerUrl(videoPageHtml, videoId, s) if(playerUrlJson) { } else { log("Ignoring videos with encrypted signatures") return } } } String qs = streamUrl.substring(streamUrl.indexOf('?') + 1) Map urlQsData = parseQueryString(qs) Date expiryDate = null if(urlQsData['expire']) { expiryDate = new Date(Long.parseLong(urlQsData['expire']) * 1000) } streamFormats.put(format['itag'].toString(), new FormatDetails(format['itag'].toString(), streamUrl, expiryDate)) } def parseQueryString(String qs) { Map params = [:] qs.split('&').each { item2 -> addParameter(item2, params, '=') } params } def getPlayerUrl(String videoPageHtml, String videoId, String exampleSignature) { def playerMatcher = videoPageHtml =~ PLAYER_REGEX def playerUrl = playerMatcher[0][1] if(playerUrl) { String playerPath = StringEscapeUtils.unescapeJavaScript(playerUrl) String fullPlayerUrl = null if(playerPath.startsWith('//')) { fullPlayerUrl = "https:$playerPath" } else if(!playerPath.startsWith("http")) { fullPlayerUrl = "https://www.youtube.com$playerPath" } if(fullPlayerUrl) { // todo cache the calculateSignture(fullPlayerUrl, videoId, exampleSignature) } } return null } def calculateSignture(String playerUrl, String videoId, String exampleSignature) { def playerIdMatcher = playerUrl =~ '.*?-([a-zA-Z0-9_-]+)(?:\\/watch_as3|\\/html5player(?:-new)?|(?:\\/[a-z]{2,3}_[A-Z]{2})?\\/base)?\\.([a-z]+)$' String playerId = playerIdMatcher[0][1] String playerExt = playerIdMatcher[0][2] if(playerExt == "js") { // String playerSource = openURL(new URL(playerUrl), user_agent) //todo cache this // ScriptEngine engine = new ScriptEngineManager().getEngineByName("nashorn"); // engine.eval("") //todo can we implement signatures? https://github.com/ytdl-org/youtube-dl/blob/c3cfea906869e8358652e382679a5996c2aec73e/youtube_dl/extractor/youtube.py } else { // swf supported? } return null } String getCacheKey(String videoId, String qualityId) { "youtube_${videoId}_${qualityId}" } def sortAvailableFormatUrls2(List formatIds, Map sourceMap, Map targetMap) { formatIds.each { formatId -> if (sourceMap.containsKey(formatId)) { targetMap.put(formatId, sourceMap.get(formatId)) } } } static void main(args) { // this is just to test YouTube extractor = new YouTube() assert extractor.extractorMatches(new URL("https://www.googleapis.com/youtube/v3/playlistItems?playlistId=YOUR_PLAYLIST_ID_HERE")) assert !extractor.extractorMatches(new URL("http://google.com/feeds/api/standardfeeds/top_rated?time=today")) WebResourceContainer container1 = extractor.extractItems(new URL("https://www.googleapis.com/youtube/v3/playlistItems?playlistId=PLx6bGx4zt6EmUH0nP0Vbny7qbGABlrxnr"), 10) println container1 ContentURLContainer result1 = extractor.extractUrl(container1.getItems()[2], PreferredQuality.MEDIUM) println result1 println "" WebResourceContainer container2 = extractor.extractItems(new URL("https://www.googleapis.com/youtube/v3/videos?chart=mostPopular"), 10) println container2 ContentURLContainer result2 = extractor.extractUrl(container2.getItems()[2], PreferredQuality.MEDIUM) println result2 println "" WebResourceContainer container3 = extractor.extractItems(new URL("https://www.googleapis.com/youtube/v3/channels?forUsername=NFL"), 10) println container3 ContentURLContainer result3 = extractor.extractUrl(container3.getItems()[2], PreferredQuality.MEDIUM) println result3 println "" WebResourceContainer container4 = extractor.extractItems(new URL("https://www.googleapis.com/youtube/v3/search?q=crazy"), 10) println container4 container4.getItems().forEach { it -> println(extractor.extractUrl(it, PreferredQuality.MEDIUM)) } // live hls // println "" // WebResourceContainer container5 = extractor.extractItems( new URL("https://www.googleapis.com/youtube/v3/videos?id=sw4hmqVPe0E"), 10) // println container5 // ContentURLContainer result5 = extractor.extractUrl(container5.getItems()[0], PreferredQuality.MEDIUM) // println result5 } }