Prefetch YouTube video info on hover

Speed up loading of YouTube videos by downloading and parsing their info before video is dropped into player.
This commit is contained in:
Rafał Dzięgiel
2021-03-12 13:05:58 +01:00
parent 01c26cbbc3
commit c89d488c30
3 changed files with 129 additions and 14 deletions

View File

@@ -26,6 +26,7 @@ class ClapperPlayer extends PlayerBase
this.needsTocUpdate = true;
this.keyPressCount = 0;
this.ytClient = null;
const keyController = new Gtk.EventControllerKey();
keyController.connect('key-pressed', this._onWidgetKeyPressed.bind(this));
@@ -78,8 +79,10 @@ class ClapperPlayer extends PlayerBase
async getYouTubeUriAsync(videoId)
{
const client = new YouTube.YouTubeClient();
const info = await client.getVideoInfoPromise(videoId).catch(debug);
if(!this.ytClient)
this.ytClient = new YouTube.YouTubeClient();
const info = await this.ytClient.getVideoInfoPromise(videoId).catch(debug);
if(!info)
throw new Error('no YouTube video info');
@@ -87,7 +90,7 @@ class ClapperPlayer extends PlayerBase
const dash = Dash.generateDash(info);
const videoUri = (dash)
? await Dash.saveDashPromise(dash).catch(debug)
: client.getBestCombinedUri(info);
: this.ytClient.getBestCombinedUri(info);
if(!videoUri)
throw new Error('no YouTube video URI');

View File

@@ -4,6 +4,7 @@ const Debug = imports.src.debug;
const Dialogs = imports.src.dialogs;
const Misc = imports.src.misc;
const { Player } = imports.src.player;
const YouTube = imports.src.youtube;
const Revealers = imports.src.revealers;
const { debug } = Debug;
@@ -721,9 +722,11 @@ class ClapperWidget extends Gtk.Grid
{
const dropTarget = new Gtk.DropTarget({
actions: Gdk.DragAction.COPY,
preload: true,
});
dropTarget.set_gtypes([GObject.TYPE_STRING]);
dropTarget.connect('drop', this._onDataDrop.bind(this));
dropTarget.connect('notify::value', this._onDropValueNotify.bind(this));
return dropTarget;
}
@@ -897,6 +900,31 @@ class ClapperWidget extends Gtk.Grid
this.posY = posY;
}
_onDropValueNotify(dropTarget)
{
if(!dropTarget.value)
return;
const uris = dropTarget.value.split(/\r?\n/);
const firstUri = uris[0];
if(uris.length > 1 || !Gst.uri_is_valid(firstUri))
return;
/* Check if user is dragging a YouTube link */
const [isYouTubeUri, videoId] = YouTube.checkYouTubeUri(firstUri);
if(!isYouTubeUri) return;
/* Since this is a YouTube video,
* create YT client if it was not created yet */
if(!this.player.ytClient)
this.player.ytClient = new YouTube.YouTubeClient();
/* Speed up things by prefetching new video info before drop */
if(!this.player.ytClient.compareLastVideoId(videoId))
this.player.ytClient.getVideoInfoPromise(videoId).catch(debug);
}
_onDataDrop(dropTarget, value, x, y)
{
const playlist = value.split(/\r?\n/).filter(uri => {

View File

@@ -4,27 +4,54 @@ const Debug = imports.src.debug;
const { debug } = Debug;
var YouTubeClient = GObject.registerClass(
class ClapperYouTubeClient extends Soup.Session
var YouTubeClient = GObject.registerClass({
Signals: {
'info-resolved': {
param_types: [GObject.TYPE_BOOLEAN]
}
}
}, class ClapperYouTubeClient extends Soup.Session
{
_init()
{
super._init({
timeout: 5,
});
/* videoID of current active download */
this.downloadingVideoId = null;
this.downloadAborted = false;
this.lastInfo = null;
}
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;
/* If in middle of download and same videoID,
* resolve to current download */
if(
this.downloadingVideoId
&& this.downloadingVideoId === videoId
)
return this._getCurrentDownloadPromise();
return new Promise(async (resolve, reject) => {
/* Do not redownload info for the same video */
if(this.compareLastVideoId(videoId))
return resolve(this.lastInfo);
this.abort();
let tries = 2;
while(tries--) {
debug(`obtaining YouTube video info: ${videoId}`);
this.downloadingVideoId = videoId;
const info = await this._getInfoPromise(url).catch(debug);
const info = await this._getInfoPromise(videoId).catch(debug);
if(!info) {
if(this.downloadAborted)
return reject(new Error('download aborted'));
debug(`failed, remaining tries: ${tries}`);
continue;
}
@@ -33,16 +60,31 @@ class ClapperYouTubeClient extends Soup.Session
if(
!info.playabilityStatus
|| !info.playabilityStatus.status === 'OK'
)
) {
this.emit('info-resolved', false);
this.downloadingVideoId = null;
return reject(new Error('video is not playable'));
}
/* Check if data contains streaming URIs */
if(!info.streamingData)
if(!info.streamingData) {
this.emit('info-resolved', false);
this.downloadingVideoId = null;
return reject(new Error('video response data is missing URIs'));
}
this.lastInfo = info;
this.emit('info-resolved', true);
this.downloadingVideoId = null;
return resolve(info);
}
this.emit('info-resolved', false);
this.downloadingVideoId = null;
reject(new Error('could not obtain YouTube video info'));
});
}
@@ -65,9 +107,45 @@ class ClapperYouTubeClient extends Soup.Session
return combinedStream.url;
}
_getInfoPromise(url)
compareLastVideoId(videoId)
{
if(!this.lastInfo)
return false;
if(
!this.lastInfo
|| !this.lastInfo.videoDetails
|| this.lastInfo.videoDetails.videoId !== videoId
/* TODO: check if video expired */
)
return false;
return true;
}
_getCurrentDownloadPromise()
{
debug('resolving after current download finishes');
return new Promise((resolve, reject) => {
const infoResolvedSignal = this.connect('info-resolved', (self, success) => {
this.disconnect(infoResolvedSignal);
debug('current download finished, resolving');
if(!success)
return reject(new Error('info resolve was unsuccessful'));
/* At this point new video info is set */
resolve(this.lastInfo);
});
});
}
_getInfoPromise(videoId)
{
return new Promise((resolve, reject) => {
const url = `https://www.youtube.com/get_video_info?video_id=${videoId}&el=embedded`;
const message = Soup.Message.new('GET', url);
let data = '';
@@ -85,8 +163,14 @@ class ClapperYouTubeClient extends Soup.Session
debug('got message response');
if(msg.status_code !== 200)
return reject(new Error(`response code: ${msg.status_code}`));
const statusCode = msg.status_code;
/* Internal Soup codes mean download abort
* or some other error that cannot be handled */
this.downloadAborted = (statusCode < 10);
if(statusCode !== 200)
return reject(new Error(`response code: ${statusCode}`));
debug('parsing video info JSON');