YouTube support. Closes #46

This commit is contained in:
Rafał Dzięgiel
2021-03-11 17:34:54 +01:00
parent 6dc37088cf
commit fceb8ff70a
6 changed files with 406 additions and 6 deletions

202
src/dash.js Normal file
View File

@@ -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 [
`<?xml version="1.0" encoding="UTF-8"?>`,
`<MPD xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"`,
` xmlns="urn:mpeg:dash:schema:mpd:2011"`,
` xsi:schemaLocation="urn:mpeg:dash:schema:mpd:2011 DASH-MPD.xsd"`,
` type="static"`,
` mediaPresentationDuration="PT${info.videoDetails.lengthSeconds}S"`,
` minBufferTime="PT${bufferSec}S"`,
` profiles="urn:mpeg:dash:profile:isoff-on-demand:2011">`,
` <Period>`,
_addAdaptationSet([videoStream]),
_addAdaptationSet([audioStream]),
` </Period>`,
`</MPD>`
].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 = [
` <AdaptationSet ${adaptArr.join(' ')}>`,
representations.join('\n'),
` </AdaptationSet>`
];
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 = [
` <Representation ${repOptsArr.join(' ')}>`,
];
if(stream.audioChannels) {
const audioConfArr = [
`schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011"`,
`value="${stream.audioChannels}"`,
];
repArr.push(` <AudioChannelConfiguration ${audioConfArr.join(' ')}/>`);
}
const encodedURL = Misc.encodeHTML(stream.url);
const segRange = `${stream.indexRange.start}-${stream.indexRange.end}`;
const initRange = `${stream.initRange.start}-${stream.initRange.end}`;
repArr.push(
` <BaseURL>${encodedURL}</BaseURL>`,
`<!-- FIXME: causes string query omission bug in dashdemux`,
` <SegmentBase indexRange="${segRange}">`,
` <Initialization range="${initRange}"/>`,
` </SegmentBase>`,
`-->`,
` </Representation>`,
);
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;
}

View File

@@ -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;

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

View File

@@ -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({

View File

@@ -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();

143
src/youtube.js Normal file
View File

@@ -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];
}