mirror of
https://github.com/Rafostar/clapper.git
synced 2025-08-30 07:42:23 +02:00
YouTube support. Closes #46
This commit is contained in:
202
src/dash.js
Normal file
202
src/dash.js
Normal 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;
|
||||||
|
}
|
@@ -1,8 +1,10 @@
|
|||||||
imports.gi.versions.Gdk = '4.0';
|
imports.gi.versions.Gdk = '4.0';
|
||||||
imports.gi.versions.Gtk = '4.0';
|
imports.gi.versions.Gtk = '4.0';
|
||||||
|
|
||||||
const { Gst } = imports.gi;
|
const { Gio, Gst } = imports.gi;
|
||||||
|
|
||||||
Gst.init(null);
|
Gst.init(null);
|
||||||
|
Gio._promisify(Gio._LocalFilePrototype, 'replace_contents_bytes_async', 'replace_contents_finish');
|
||||||
|
|
||||||
const { App } = imports.src.app;
|
const { App } = imports.src.app;
|
||||||
|
|
||||||
|
11
src/misc.js
11
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 = imports.src.debug;
|
||||||
|
|
||||||
const { debug } = Debug;
|
const { debug } = Debug;
|
||||||
@@ -95,3 +95,12 @@ function getFormattedTime(time, showHours)
|
|||||||
const parsed = (hours) ? `${hours}:` : '';
|
const parsed = (hours) ? `${hours}:` : '';
|
||||||
return parsed + `${minutes}:${seconds}`;
|
return parsed + `${minutes}:${seconds}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function encodeHTML(text)
|
||||||
|
{
|
||||||
|
return text.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
@@ -1,7 +1,9 @@
|
|||||||
const { Gdk, Gio, GObject, Gst, GstClapper, Gtk } = imports.gi;
|
const { Gdk, Gio, GObject, Gst, GstClapper, Gtk } = imports.gi;
|
||||||
const ByteArray = imports.byteArray;
|
const ByteArray = imports.byteArray;
|
||||||
|
const Dash = imports.src.dash;
|
||||||
const Debug = imports.src.debug;
|
const Debug = imports.src.debug;
|
||||||
const Misc = imports.src.misc;
|
const Misc = imports.src.misc;
|
||||||
|
const YouTube = imports.src.youtube;
|
||||||
const { PlayerBase } = imports.src.playerBase;
|
const { PlayerBase } = imports.src.playerBase;
|
||||||
|
|
||||||
const { debug } = Debug;
|
const { debug } = Debug;
|
||||||
@@ -17,6 +19,7 @@ class ClapperPlayer extends PlayerBase
|
|||||||
this.seek_done = true;
|
this.seek_done = true;
|
||||||
this.doneStartup = false;
|
this.doneStartup = false;
|
||||||
this.needsFastSeekRestore = false;
|
this.needsFastSeekRestore = false;
|
||||||
|
this.customVideoTitle = null;
|
||||||
|
|
||||||
this.playOnFullscreen = false;
|
this.playOnFullscreen = false;
|
||||||
this.quitOnStop = false;
|
this.quitOnStop = false;
|
||||||
@@ -40,8 +43,20 @@ class ClapperPlayer extends PlayerBase
|
|||||||
|
|
||||||
set_uri(uri)
|
set_uri(uri)
|
||||||
{
|
{
|
||||||
if(Gst.Uri.get_protocol(uri) !== 'file')
|
this.customVideoTitle = null;
|
||||||
return super.set_uri(uri);
|
|
||||||
|
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);
|
let file = Gio.file_new_for_uri(uri);
|
||||||
if(!file.query_exists(null)) {
|
if(!file.query_exists(null)) {
|
||||||
@@ -52,12 +67,38 @@ class ClapperPlayer extends PlayerBase
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(uri.endsWith('.claps'))
|
if(uri.endsWith('.claps')) {
|
||||||
return this.load_playlist_file(file);
|
this.load_playlist_file(file);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
super.set_uri(uri);
|
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)
|
load_playlist_file(file)
|
||||||
{
|
{
|
||||||
const stream = new Gio.DataInputStream({
|
const stream = new Gio.DataInputStream({
|
||||||
|
@@ -318,6 +318,9 @@ class ClapperWidget extends Gtk.Grid
|
|||||||
{
|
{
|
||||||
let title = mediaInfo.get_title();
|
let title = mediaInfo.get_title();
|
||||||
|
|
||||||
|
if(!title)
|
||||||
|
title = this.player.customVideoTitle;
|
||||||
|
|
||||||
if(!title) {
|
if(!title) {
|
||||||
const subtitle = this.player.playlistWidget.getActiveFilename();
|
const subtitle = this.player.playlistWidget.getActiveFilename();
|
||||||
|
|
||||||
|
143
src/youtube.js
Normal file
143
src/youtube.js
Normal 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];
|
||||||
|
}
|
Reference in New Issue
Block a user