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

View File

@@ -4,6 +4,7 @@ const Debug = imports.src.debug;
const Dialogs = imports.src.dialogs; const Dialogs = imports.src.dialogs;
const Misc = imports.src.misc; const Misc = imports.src.misc;
const { Player } = imports.src.player; const { Player } = imports.src.player;
const YouTube = imports.src.youtube;
const Revealers = imports.src.revealers; const Revealers = imports.src.revealers;
const { debug } = Debug; const { debug } = Debug;
@@ -721,9 +722,11 @@ class ClapperWidget extends Gtk.Grid
{ {
const dropTarget = new Gtk.DropTarget({ const dropTarget = new Gtk.DropTarget({
actions: Gdk.DragAction.COPY, actions: Gdk.DragAction.COPY,
preload: true,
}); });
dropTarget.set_gtypes([GObject.TYPE_STRING]); dropTarget.set_gtypes([GObject.TYPE_STRING]);
dropTarget.connect('drop', this._onDataDrop.bind(this)); dropTarget.connect('drop', this._onDataDrop.bind(this));
dropTarget.connect('notify::value', this._onDropValueNotify.bind(this));
return dropTarget; return dropTarget;
} }
@@ -897,6 +900,31 @@ class ClapperWidget extends Gtk.Grid
this.posY = posY; 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) _onDataDrop(dropTarget, value, x, y)
{ {
const playlist = value.split(/\r?\n/).filter(uri => { const playlist = value.split(/\r?\n/).filter(uri => {

View File

@@ -4,27 +4,54 @@ const Debug = imports.src.debug;
const { debug } = Debug; const { debug } = Debug;
var YouTubeClient = GObject.registerClass( var YouTubeClient = GObject.registerClass({
class ClapperYouTubeClient extends Soup.Session Signals: {
'info-resolved': {
param_types: [GObject.TYPE_BOOLEAN]
}
}
}, class ClapperYouTubeClient extends Soup.Session
{ {
_init() _init()
{ {
super._init({ super._init({
timeout: 5, timeout: 5,
}); });
/* videoID of current active download */
this.downloadingVideoId = null;
this.downloadAborted = false;
this.lastInfo = null;
} }
getVideoInfoPromise(videoId) getVideoInfoPromise(videoId)
{ {
return new Promise(async (resolve, reject) => { /* If in middle of download and same videoID,
const url = `https://www.youtube.com/get_video_info?video_id=${videoId}&el=embedded`; * resolve to current download */
let tries = 2; 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--) { while(tries--) {
debug(`obtaining YouTube video info: ${videoId}`); 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(!info) {
if(this.downloadAborted)
return reject(new Error('download aborted'));
debug(`failed, remaining tries: ${tries}`); debug(`failed, remaining tries: ${tries}`);
continue; continue;
} }
@@ -33,16 +60,31 @@ class ClapperYouTubeClient extends Soup.Session
if( if(
!info.playabilityStatus !info.playabilityStatus
|| !info.playabilityStatus.status === 'OK' || !info.playabilityStatus.status === 'OK'
) ) {
this.emit('info-resolved', false);
this.downloadingVideoId = null;
return reject(new Error('video is not playable')); return reject(new Error('video is not playable'));
}
/* Check if data contains streaming URIs */ /* 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')); return reject(new Error('video response data is missing URIs'));
}
this.lastInfo = info;
this.emit('info-resolved', true);
this.downloadingVideoId = null;
return resolve(info); return resolve(info);
} }
this.emit('info-resolved', false);
this.downloadingVideoId = null;
reject(new Error('could not obtain YouTube video info')); reject(new Error('could not obtain YouTube video info'));
}); });
} }
@@ -65,9 +107,45 @@ class ClapperYouTubeClient extends Soup.Session
return combinedStream.url; 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) => { 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); const message = Soup.Message.new('GET', url);
let data = ''; let data = '';
@@ -85,8 +163,14 @@ class ClapperYouTubeClient extends Soup.Session
debug('got message response'); debug('got message response');
if(msg.status_code !== 200) const statusCode = msg.status_code;
return reject(new Error(`response code: ${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'); debug('parsing video info JSON');