mirror of
https://github.com/Rafostar/clapper.git
synced 2025-08-29 23:32:04 +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.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;
|
||||
|
||||
|
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 } = 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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
@@ -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({
|
||||
|
@@ -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
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