From fceb8ff70a5ca11a4c1d22040b726dcb0df0f9a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Dzi=C4=99giel?= Date: Thu, 11 Mar 2021 17:34:54 +0100 Subject: [PATCH] YouTube support. Closes #46 --- src/dash.js | 202 +++++++++++++++++++++++++++++++++++++++++++++++++ src/main.js | 4 +- src/misc.js | 11 ++- src/player.js | 49 +++++++++++- src/widget.js | 3 + src/youtube.js | 143 ++++++++++++++++++++++++++++++++++ 6 files changed, 406 insertions(+), 6 deletions(-) create mode 100644 src/dash.js create mode 100644 src/youtube.js diff --git a/src/dash.js b/src/dash.js new file mode 100644 index 00000000..38e895cb --- /dev/null +++ b/src/dash.js @@ -0,0 +1,202 @@ +const { Gio, GLib } = imports.gi; +const Debug = imports.src.debug; +const Misc = imports.src.misc; + +const { debug } = Debug; + +function generateDash(info) +{ + if( + !info.streamingData + || !info.streamingData.adaptiveFormats + || !info.streamingData.adaptiveFormats.length + ) + return null; + + /* TODO: Options in prefs to set preferred video formats for adaptive streaming */ + const videoStream = info.streamingData.adaptiveFormats.find(stream => { + return (stream.mimeType.startsWith('video/mp4') && stream.quality === 'hd1080'); + }); + const audioStream = info.streamingData.adaptiveFormats.find(stream => { + return (stream.mimeType.startsWith('audio/mp4')); + }); + + if(!videoStream || !audioStream) + return null; + + const bufferSec = Math.min(4, info.videoDetails.lengthSeconds); + + return [ + ``, + ``, + ` `, + _addAdaptationSet([videoStream]), + _addAdaptationSet([audioStream]), + ` `, + `` + ].join('\n'); +} + +function saveDashPromise(dash) +{ + return new Promise((resolve, reject) => { + const tempPath = GLib.get_tmp_dir() + '/.clapper.mpd'; + const dashFile = Gio.File.new_for_path(tempPath); + + debug('saving dash file'); + + dashFile.replace_contents_bytes_async( + GLib.Bytes.new_take(dash), + null, + false, + Gio.FileCreateFlags.NONE, + null + ) + .then(() => { + debug('saved dash file'); + resolve(dashFile.get_uri()); + }) + .catch(err => reject(err)); + }); +} + +function _addAdaptationSet(streamsArr) +{ + const mimeInfo = _getMimeInfo(streamsArr[0].mimeType); + + const adaptArr = [ + `contentType="${mimeInfo.content}"`, + `mimeType="${mimeInfo.type}"`, + `subsegmentAlignment="true"`, + `subsegmentStartsWithSAP="1"`, + ]; + + const widthArr = []; + const heightArr = []; + const fpsArr = []; + + const representations = []; + + for(let stream of streamsArr) { + /* No point parsing if no URL */ + if(!stream.url) + continue; + + if(stream.width && stream.height) { + widthArr.push(stream.width); + heightArr.push(stream.height); + } + if(stream.fps) + fpsArr.push(stream.fps); + + representations.push(_getStreamRepresentation(stream)); + } + + if(widthArr.length && heightArr.length) { + const maxWidth = Math.max.apply(null, widthArr); + const maxHeight = Math.max.apply(null, heightArr); + const par = _getPar(maxWidth, maxHeight); + + adaptArr.push(`maxWidth="${maxWidth}"`); + adaptArr.push(`maxHeight="${maxHeight}"`); + adaptArr.push(`par="${par}"`); + } + if(fpsArr.length) { + const maxFps = Math.max.apply(null, fpsArr); + + adaptArr.push(`maxFrameRate="${maxFps}"`); + } + + const adaptationSet = [ + ` `, + representations.join('\n'), + ` ` + ]; + + return adaptationSet.join('\n'); +} + +function _getStreamRepresentation(stream) +{ + const mimeInfo = _getMimeInfo(stream.mimeType); + + const repOptsArr = [ + `id="${stream.itag}"`, + `codecs="${mimeInfo.codecs}"`, + `bandwidth="${stream.bitrate}"`, + ]; + + if(stream.width && stream.height) { + repOptsArr.push(`width="${stream.width}"`); + repOptsArr.push(`height="${stream.height}"`); + repOptsArr.push(`sar="1:1"`); + } + if(stream.fps) + repOptsArr.push(`frameRate="${stream.fps}"`); + + const repArr = [ + ` `, + ]; + if(stream.audioChannels) { + const audioConfArr = [ + `schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011"`, + `value="${stream.audioChannels}"`, + ]; + repArr.push(` `); + } + + const encodedURL = Misc.encodeHTML(stream.url); + const segRange = `${stream.indexRange.start}-${stream.indexRange.end}`; + const initRange = `${stream.initRange.start}-${stream.initRange.end}`; + + repArr.push( + ` ${encodedURL}`, + ``, + ` `, + ); + + return repArr.join('\n'); +} + +function _getMimeInfo(mimeType) +{ + const mimeArr = mimeType.split(';'); + + let codecs = mimeArr.find(info => info.includes('codecs')).split('=')[1]; + codecs = codecs.substring(1, codecs.length - 1); + + const mimeInfo = { + content: mimeArr[0].split('/')[0], + type: mimeArr[0], + codecs, + }; + + return mimeInfo; +} + +function _getPar(width, height) +{ + const gcd = _getGCD(width, height); + + width /= gcd; + height /= gcd; + + return `${width}:${height}`; +} + +function _getGCD(width, height) +{ + return (height) + ? _getGCD(height, width % height) + : width; +} diff --git a/src/main.js b/src/main.js index 76d169c4..54e20d2a 100644 --- a/src/main.js +++ b/src/main.js @@ -1,8 +1,10 @@ imports.gi.versions.Gdk = '4.0'; imports.gi.versions.Gtk = '4.0'; -const { Gst } = imports.gi; +const { Gio, Gst } = imports.gi; + Gst.init(null); +Gio._promisify(Gio._LocalFilePrototype, 'replace_contents_bytes_async', 'replace_contents_finish'); const { App } = imports.src.app; diff --git a/src/misc.js b/src/misc.js index 695b4380..6e5da48a 100644 --- a/src/misc.js +++ b/src/misc.js @@ -1,4 +1,4 @@ -const { Gio, GstAudio, Gdk, Gtk } = imports.gi; +const { Gio, Gdk, Gtk } = imports.gi; const Debug = imports.src.debug; const { debug } = Debug; @@ -95,3 +95,12 @@ function getFormattedTime(time, showHours) const parsed = (hours) ? `${hours}:` : ''; return parsed + `${minutes}:${seconds}`; } + +function encodeHTML(text) +{ + return text.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/src/player.js b/src/player.js index 6e9dc054..56c12acd 100644 --- a/src/player.js +++ b/src/player.js @@ -1,7 +1,9 @@ const { Gdk, Gio, GObject, Gst, GstClapper, Gtk } = imports.gi; const ByteArray = imports.byteArray; +const Dash = imports.src.dash; const Debug = imports.src.debug; const Misc = imports.src.misc; +const YouTube = imports.src.youtube; const { PlayerBase } = imports.src.playerBase; const { debug } = Debug; @@ -17,6 +19,7 @@ class ClapperPlayer extends PlayerBase this.seek_done = true; this.doneStartup = false; this.needsFastSeekRestore = false; + this.customVideoTitle = null; this.playOnFullscreen = false; this.quitOnStop = false; @@ -40,8 +43,20 @@ class ClapperPlayer extends PlayerBase set_uri(uri) { - if(Gst.Uri.get_protocol(uri) !== 'file') - return super.set_uri(uri); + this.customVideoTitle = null; + + if(Gst.Uri.get_protocol(uri) !== 'file') { + const [isYouTubeUri, videoId] = YouTube.checkYouTubeUri(uri); + + if(!isYouTubeUri) + return super.set_uri(uri); + + this.getYouTubeUriAsync(videoId) + .then(ytUri => super.set_uri(ytUri)) + .catch(debug); + + return; + } let file = Gio.file_new_for_uri(uri); if(!file.query_exists(null)) { @@ -52,12 +67,38 @@ class ClapperPlayer extends PlayerBase return; } - if(uri.endsWith('.claps')) - return this.load_playlist_file(file); + if(uri.endsWith('.claps')) { + this.load_playlist_file(file); + + return; + } super.set_uri(uri); } + async getYouTubeUriAsync(videoId) + { + const client = new YouTube.YouTubeClient(); + const info = await client.getVideoInfoPromise(videoId).catch(debug); + + if(!info) + throw new Error('no YouTube video info'); + + const dash = Dash.generateDash(info); + const videoUri = (dash) + ? await Dash.saveDashPromise(dash).catch(debug) + : client.getBestCombinedUri(info); + + if(!videoUri) + throw new Error('no YouTube video URI'); + + this.customVideoTitle = (info.videoDetails && info.videoDetails.title) + ? info.videoDetails.title + : videoId; + + return videoUri; + } + load_playlist_file(file) { const stream = new Gio.DataInputStream({ diff --git a/src/widget.js b/src/widget.js index b3252e77..fc852954 100644 --- a/src/widget.js +++ b/src/widget.js @@ -318,6 +318,9 @@ class ClapperWidget extends Gtk.Grid { let title = mediaInfo.get_title(); + if(!title) + title = this.player.customVideoTitle; + if(!title) { const subtitle = this.player.playlistWidget.getActiveFilename(); diff --git a/src/youtube.js b/src/youtube.js new file mode 100644 index 00000000..b4d832d9 --- /dev/null +++ b/src/youtube.js @@ -0,0 +1,143 @@ +const { GLib, GObject, Gst, Soup } = imports.gi; +const ByteArray = imports.byteArray; +const Debug = imports.src.debug; + +const { debug } = Debug; + +var YouTubeClient = GObject.registerClass( +class ClapperYouTubeClient extends Soup.Session +{ + _init() + { + super._init({ + timeout: 5, + }); + } + + getVideoInfoPromise(videoId) + { + return new Promise(async (resolve, reject) => { + const url = `https://www.youtube.com/get_video_info?video_id=${videoId}&el=embedded`; + let tries = 2; + + while(tries--) { + debug(`obtaining YouTube video info: ${videoId}`); + + const info = await this._getInfoPromise(url).catch(debug); + if(!info) { + debug(`failed, remaining tries: ${tries}`); + continue; + } + + /* Check if video is playable */ + if( + !info.playabilityStatus + || !info.playabilityStatus.status === 'OK' + ) + return reject(new Error('video is not playable')); + + /* Check if data contains streaming URIs */ + if(!info.streamingData) + return reject(new Error('video response data is missing URIs')); + + return resolve(info); + } + + reject(new Error('could not obtain YouTube video info')); + }); + } + + getBestCombinedUri(info) + { + if( + !info.streamingData.formats + || !info.streamingData.formats.length + ) + return null; + + const combinedStream = info.streamingData.formats[ + info.streamingData.formats.length - 1 + ]; + + if(!combinedStream || !combinedStream.url) + return null; + + return combinedStream.url; + } + + _getInfoPromise(url) + { + return new Promise((resolve, reject) => { + const message = Soup.Message.new('GET', url); + let data = ''; + + const chunkSignal = message.connect('got-chunk', (msg, chunk) => { + debug(`got chunk of data, length: ${chunk.length}`); + + const chunkData = chunk.get_data(); + data += (chunkData instanceof Uint8Array) + ? ByteArray.toString(chunkData) + : chunkData; + }); + + this.queue_message(message, (session, msg) => { + msg.disconnect(chunkSignal); + + debug('got message response'); + + if(msg.status_code !== 200) + return reject(new Error(`response code: ${msg.status_code}`)); + + debug('parsing video info JSON'); + + const gstUri = Gst.Uri.from_string('?' + data); + + if(!gstUri) + return reject(new Error('could not convert query to URI')); + + const playerResponse = gstUri.get_query_value('player_response'); + + if(!playerResponse) + return reject(new Error('no player response in query')); + + let info = null; + + try { info = JSON.parse(playerResponse); } + catch(err) { debug(err.message) } + + if(!info) + return reject(new Error('could not parse video info JSON')); + + debug('successfully parsed video info JSON'); + + resolve(info); + }); + }); + } +}); + +function checkYouTubeUri(uri) +{ + const gstUri = Gst.Uri.from_string(uri); + gstUri.normalize(); + + const host = gstUri.get_host(); + + let success = true; + let videoId = null; + + switch(host) { + case 'www.youtube.com': + case 'youtube.com': + videoId = gstUri.get_query_value('v'); + break; + case 'youtu.be': + videoId = gstUri.get_path_segments()[1]; + break; + default: + success = false; + break; + } + + return [success, videoId]; +}