2 Commits

Author SHA1 Message Date
Rafał Dzięgiel
2076309aaa flatpak: Build gtuber 2021-10-18 15:32:55 +02:00
Rafał Dzięgiel
79618edd1e Port to new gtuber lib
Current YouTube code was broken for quite some time. Replace it with
the new Gtuber lib to make this code separate, independent and easier to maintain.
2021-10-18 15:28:34 +02:00
16 changed files with 363 additions and 1346 deletions

View File

@@ -103,14 +103,14 @@
<summary>Set PlayFlags for playbin</summary>
</key>
<!-- YouTube -->
<!-- Gtuber -->
<key name="yt-adaptive-enabled" type="b">
<default>false</default>
<summary>Enable to use adaptive streaming for YouTube</summary>
<summary>Enable to use adaptive streaming</summary>
</key>
<key name="yt-quality-type" type="i">
<default>1</default>
<summary>Max YouTube video quality type</summary>
<summary>Max online video quality type</summary>
</key>
<!-- Other -->

View File

@@ -33,6 +33,7 @@
"flathub/lib/libass.json",
"flathub/lib/ffmpeg.json",
"testing/gstreamer.json",
"testing/gtuber.json",
{
"name": "clapper",
"buildsystem": "meson",

View File

@@ -42,6 +42,7 @@
"flathub/gstreamer-1.0/gstreamer-vaapi.json",
"flathub/lib/gtk4.json",
"flathub/lib/libadwaita.json",
"testing/gtuber.json",
{
"name": "clapper",
"buildsystem": "meson",

View File

@@ -0,0 +1,18 @@
{
"name": "gtuber",
"buildsystem": "meson",
"config-opts": [
"-Dvapi=disabled"
],
"cleanup": [
"/include",
"/lib/pkgconfig"
],
"sources": [
{
"type": "git",
"url": "https://github.com/Rafostar/gtuber.git",
"branch": "main"
}
]
}

View File

@@ -1,151 +0,0 @@
/* Copyright (C) 2012-present by fent
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
const jsVarStr = '[a-zA-Z_\\$][a-zA-Z_0-9]*';
const jsSingleQuoteStr = `'[^'\\\\]*(:?\\\\[\\s\\S][^'\\\\]*)*'`;
const jsDoubleQuoteStr = `"[^"\\\\]*(:?\\\\[\\s\\S][^"\\\\]*)*"`;
const jsQuoteStr = `(?:${jsSingleQuoteStr}|${jsDoubleQuoteStr})`;
const jsKeyStr = `(?:${jsVarStr}|${jsQuoteStr})`;
const jsPropStr = `(?:\\.${jsVarStr}|\\[${jsQuoteStr}\\])`;
const jsEmptyStr = `(?:''|"")`;
const reverseStr = ':function\\(a\\)\\{' +
'(?:return )?a\\.reverse\\(\\)' +
'\\}';
const sliceStr = ':function\\(a,b\\)\\{' +
'return a\\.slice\\(b\\)' +
'\\}';
const spliceStr = ':function\\(a,b\\)\\{' +
'a\\.splice\\(0,b\\)' +
'\\}';
const swapStr = ':function\\(a,b\\)\\{' +
'var c=a\\[0\\];a\\[0\\]=a\\[b(?:%a\\.length)?\\];a\\[b(?:%a\\.length)?\\]=c(?:;return a)?' +
'\\}';
const actionsObjRegexp = new RegExp(
`var (${jsVarStr})=\\{((?:(?:${
jsKeyStr}${reverseStr}|${
jsKeyStr}${sliceStr}|${
jsKeyStr}${spliceStr}|${
jsKeyStr}${swapStr
}),?\\r?\\n?)+)\\};`);
const actionsFuncRegexp = new RegExp(`${`function(?: ${jsVarStr})?\\(a\\)\\{` +
`a=a\\.split\\(${jsEmptyStr}\\);\\s*` +
`((?:(?:a=)?${jsVarStr}`}${
jsPropStr
}\\(a,\\d+\\);)+)` +
`return a\\.join\\(${jsEmptyStr}\\)` +
`\\}`);
const reverseRegexp = new RegExp(`(?:^|,)(${jsKeyStr})${reverseStr}`, 'm');
const sliceRegexp = new RegExp(`(?:^|,)(${jsKeyStr})${sliceStr}`, 'm');
const spliceRegexp = new RegExp(`(?:^|,)(${jsKeyStr})${spliceStr}`, 'm');
const swapRegexp = new RegExp(`(?:^|,)(${jsKeyStr})${swapStr}`, 'm');
const swapHeadAndPosition = (arr, position) => {
const first = arr[0];
arr[0] = arr[position % arr.length];
arr[position] = first;
return arr;
}
function decipher(sig, tokens) {
sig = sig.split('');
tokens = tokens.split(',');
for(let i = 0, len = tokens.length; i < len; i++) {
let token = tokens[i], pos;
switch (token[0]) {
case 'r':
sig = sig.reverse();
break;
case 'w':
pos = ~~token.slice(1);
sig = swapHeadAndPosition(sig, pos);
break;
case 's':
pos = ~~token.slice(1);
sig = sig.slice(pos);
break;
case 'p':
pos = ~~token.slice(1);
sig.splice(0, pos);
break;
}
}
return sig.join('');
};
function extractActions(body) {
const objResult = actionsObjRegexp.exec(body);
const funcResult = actionsFuncRegexp.exec(body);
if(!objResult || !funcResult)
return null;
const obj = objResult[1].replace(/\$/g, '\\$');
const objBody = objResult[2].replace(/\$/g, '\\$');
const funcBody = funcResult[1].replace(/\$/g, '\\$');
let result = reverseRegexp.exec(objBody);
const reverseKey = result && result[1]
.replace(/\$/g, '\\$')
.replace(/\$|^'|^"|'$|"$/g, '');
result = sliceRegexp.exec(objBody);
const sliceKey = result && result[1]
.replace(/\$/g, '\\$')
.replace(/\$|^'|^"|'$|"$/g, '');
result = spliceRegexp.exec(objBody);
const spliceKey = result && result[1]
.replace(/\$/g, '\\$')
.replace(/\$|^'|^"|'$|"$/g, '');
result = swapRegexp.exec(objBody);
const swapKey = result && result[1]
.replace(/\$/g, '\\$')
.replace(/\$|^'|^"|'$|"$/g, '');
const keys = `(${[reverseKey, sliceKey, spliceKey, swapKey].join('|')})`;
const myreg = `(?:a=)?${obj
}(?:\\.${keys}|\\['${keys}'\\]|\\["${keys}"\\])` +
`\\(a,(\\d+)\\)`;
const tokenizeRegexp = new RegExp(myreg, 'g');
const tokens = [];
while((result = tokenizeRegexp.exec(funcBody)) !== null) {
const key = result[1] || result[2] || result[3];
const pos = result[4];
switch (key) {
case swapKey:
tokens.push(`w${result[4]}`);
break;
case reverseKey:
tokens.push('r');
break;
case sliceKey:
tokens.push(`s${result[4]}`);
break;
case spliceKey:
tokens.push(`p${result[4]}`);
break;
}
}
return tokens.join(',');
}

View File

@@ -14,22 +14,13 @@ const clapperDebugger = new Debug.Debugger('Clapper', {
}),
high_precision: true,
});
clapperDebugger.enabled = (
var enabled = (
clapperDebugger.enabled
|| G_DEBUG_ENV != null
&& G_DEBUG_ENV.includes('Clapper')
);
const ytDebugger = new Debug.Debugger('YouTube', {
name_printer: new Ink.Printer({
font: Ink.Font.BOLD,
color: Ink.Color.RED
}),
time_printer: new Ink.Printer({
color: Ink.Color.LIGHT_BLUE
}),
high_precision: true,
});
clapperDebugger.enabled = enabled;
function _logStructured(debuggerName, msg, level)
{
@@ -43,23 +34,12 @@ function _logStructured(debuggerName, msg, level)
function _debug(debuggerName, msg)
{
if(msg.message) {
_logStructured(
debuggerName,
msg.message,
_logStructured(debuggerName, msg.message,
GLib.LogLevelFlags.LEVEL_CRITICAL
);
return;
}
switch(debuggerName) {
case 'Clapper':
clapperDebugger.debug(msg);
break;
case 'YouTube':
ytDebugger.debug(msg);
break;
}
}
function debug(msg)
@@ -67,12 +47,12 @@ function debug(msg)
_debug('Clapper', msg);
}
function ytDebug(msg)
{
_debug('YouTube', msg);
}
function warn(msg)
{
_logStructured('Clapper', msg, GLib.LogLevelFlags.LEVEL_WARNING);
}
function message(msg)
{
_logStructured('Clapper', msg, GLib.LogLevelFlags.LEVEL_MESSAGE);
}

297
src/gtuber.js Normal file
View File

@@ -0,0 +1,297 @@
const { Gio, GstClapper } = imports.gi;
const Debug = imports.src.debug;
const Misc = imports.src.misc;
const FileOps = imports.src.fileOps;
const Gtuber = Misc.tryImport('Gtuber');
const { debug, warn } = Debug;
const { settings } = Misc;
const best = {
video: null,
audio: null,
video_audio: null,
};
const codecPairs = [];
const qualityType = {
0: 30, // normal
1: 60, // hfr
};
var isAvailable = (Gtuber != null);
var cancellable = null;
let client = null;
function resetBestStreams()
{
best.video = null;
best.audio = null;
best.video_audio = null;
}
function isStreamAllowed(stream, opts)
{
const vcodec = stream.video_codec;
const acodec = stream.audio_codec;
if(
vcodec
&& (!vcodec.startsWith(opts.vcodec)
|| (stream.height < 240 || stream.height > opts.height)
|| stream.fps > qualityType[opts.quality])
) {
return false;
}
if(
acodec
&& (!acodec.startsWith(opts.acodec))
) {
return false;
}
return (vcodec != null || acodec != null);
}
function updateBestStreams(streams, opts)
{
for(let stream of streams) {
if(!isStreamAllowed(stream, opts))
continue;
const type = (stream.video_codec && stream.audio_codec)
? 'video_audio'
: (stream.video_codec)
? 'video'
: 'audio';
if(!best[type] || best[type].bitrate < stream.bitrate)
best[type] = stream;
}
}
function _streamFilter(opts, stream)
{
switch(stream) {
case best.video:
return (best.audio != null || best.video_audio == null);
case best.audio:
return (best.video != null || best.video_audio == null);
case best.video_audio:
return (best.video == null || best.audio == null);
default:
return (opts.adaptive)
? isStreamAllowed(stream, opts)
: false;
}
}
function generateManifest(info, opts)
{
const gen = new Gtuber.ManifestGenerator({
pretty: Debug.enabled,
});
gen.set_media_info(info);
gen.set_filter_func(_streamFilter.bind(this, opts));
debug('trying to get manifest');
for(let pair of codecPairs) {
opts.vcodec = pair[0];
opts.acodec = pair[1];
/* Find best streams among adaptive ones */
if (!opts.adaptive)
updateBestStreams(info.get_adaptive_streams(), opts);
const data = gen.to_data();
/* Release our ref */
if (!opts.adaptive)
resetBestStreams();
if(data) {
debug('got manifest');
return data;
}
}
debug('manifest not generated');
return null;
}
function getBestCombinedUri(info, opts)
{
const streams = info.get_streams();
debug('searching for best combined URI');
for(let pair of codecPairs) {
opts.vcodec = pair[0];
opts.acodec = pair[1];
/* Find best non-adaptive stream */
updateBestStreams(streams, opts);
const bestUri = (best.video_audio)
? best.video_audio.get_uri()
: (best.audio)
? best.audio.get_uri()
: (best.video)
? best.video.get_uri()
: null;
/* Release our ref */
resetBestStreams();
if(bestUri) {
debug('got best possible URI');
return bestUri;
}
}
/* If still nothing find stream by height */
for(let stream of streams) {
const height = stream.get_height();
if(!height || height > opts.height)
continue;
if(!best.video_audio || best.video_audio.height < stream.height)
best.video_audio = stream;
}
const anyUri = (best.video_audio)
? best.video_audio.get_uri()
: null;
/* Release our ref */
resetBestStreams();
if (anyUri)
debug('got any URI');
return anyUri;
}
async function _parseMediaInfoAsync(info, player)
{
const resp = {
uri: null,
title: info.title,
};
const { root } = player.widget;
const surface = root.get_surface();
const monitor = root.display.get_monitor_at_surface(surface);
const opts = {
width: monitor.geometry.width * monitor.scale_factor,
height: monitor.geometry.height * monitor.scale_factor,
vcodec: null,
acodec: null,
quality: settings.get_int('yt-quality-type'),
adaptive: settings.get_boolean('yt-adaptive-enabled'),
};
if(info.has_adaptive_streams) {
const data = generateManifest(info, opts);
if(data) {
const manifestFile = await FileOps.saveFilePromise(
'tmp', null, 'manifest', data
).catch(debug);
if(!manifestFile)
throw new Error('Gtuber: no manifest file was generated');
resp.uri = manifestFile.get_uri();
return resp;
}
}
resp.uri = getBestCombinedUri(info, opts);
if(!resp.uri)
throw new Error("Gtuber: no compatible stream found");
return resp;
}
function _createClient(player)
{
client = new Gtuber.Client();
debug('created new gtuber client');
/* TODO: config based on what HW supports */
//codecPairs.push(['vp9', 'opus']);
codecPairs.push(['avc', 'mp4a']);
}
function mightHandleUri(uri)
{
const unsupported = [
'file', 'fd', 'dvd', 'cdda',
'dvb', 'v4l2', 'gs'
];
return !unsupported.includes(Misc.getUriProtocol(uri));
}
function cancelFetching()
{
if(cancellable && !cancellable.is_cancelled())
cancellable.cancel();
}
function parseUriPromise(uri, player)
{
return new Promise((resolve, reject) => {
if(!client) {
if(!isAvailable) {
debug('gtuber is not installed');
return resolve({ uri, title: null });
}
_createClient(player);
}
/* Stop to show reaction and restore internet bandwidth */
if(player.state !== GstClapper.ClapperState.STOPPED)
player.stop();
cancellable = new Gio.Cancellable();
debug('gtuber is fetching media info...');
client.fetch_media_info_async(uri, cancellable, (client, task) => {
cancellable = null;
let info = null;
try {
info = client.fetch_media_info_finish(task);
debug('gtuber successfully fetched media info');
}
catch(err) {
const taskCancellable = task.get_cancellable();
if(taskCancellable.is_cancelled())
return reject(err);
const gtuberNoPlugin = (
err.domain === Gtuber.ClientError.quark()
&& err.code === Gtuber.ClientError.NO_PLUGIN
);
if(!gtuberNoPlugin)
return reject(err);
warn(`Gtuber: ${err.message}, trying URI as is...`);
/* Allow handling URI as is via GStreamer plugins */
return resolve({ uri, title: null });
}
_parseMediaInfoAsync(info, player)
.then(resp => resolve(resp))
.catch(err => reject(err));
});
});
}

View File

@@ -1,6 +1,7 @@
imports.gi.versions.Gdk = '4.0';
imports.gi.versions.Gtk = '4.0';
imports.gi.versions.Soup = '2.4';
imports.gi.versions.Gtuber = '0.0';
pkg.initGettext();
pkg.initFormat();

View File

@@ -1,6 +1,7 @@
imports.gi.versions.Gdk = '4.0';
imports.gi.versions.Gtk = '4.0';
imports.gi.versions.Soup = '2.4';
imports.gi.versions.Gtuber = '0.0';
pkg.initGettext();

View File

@@ -1,7 +1,8 @@
const { Gio, GLib, Gdk, Gtk } = imports.gi;
const Debug = imports.src.debug;
const { debug } = Debug;
const { debug, message } = Debug;
const failedImports = [];
var appName = 'Clapper';
var appId = 'com.github.rafostar.Clapper';
@@ -28,6 +29,23 @@ const subsKeys = Object.keys(subsTitles);
let inhibitCookie;
function tryImport(libName)
{
let lib = null;
try {
lib = imports.gi[libName];
}
catch(err) {
if(!failedImports.includes(libName)) {
failedImports.push(libName);
message(err.message);
}
}
return lib;
}
function getResourceUri(path)
{
const res = `file://${pkg.pkgdatadir}/${path}`;
@@ -224,22 +242,3 @@ function getIsTouch(gesture)
return false;
}
}
function encodeHTML(text)
{
return text.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function decodeURIPlus(uri)
{
return decodeURI(uri.replace(/\+/g, ' '));
}
function isHex(num)
{
return Boolean(num.match(/[0-9a-f]+$/i));
}

View File

@@ -2,7 +2,7 @@ const { Gdk, Gio, GObject, Gst, GstClapper, Gtk } = imports.gi;
const ByteArray = imports.byteArray;
const Debug = imports.src.debug;
const Misc = imports.src.misc;
const YouTube = imports.src.youtube;
const Gtuber = imports.src.gtuber;
const { PlaylistWidget } = imports.src.playlist;
const { WebApp } = imports.src.webApp;
@@ -45,7 +45,6 @@ class ClapperPlayer extends GstClapper.Clapper
this.webserver = null;
this.webapp = null;
this.ytClient = null;
this.playlistWidget = new PlaylistWidget();
this.seekDone = true;
@@ -142,24 +141,13 @@ class ClapperPlayer extends GstClapper.Clapper
set_uri(uri)
{
this.customVideoTitle = null;
Gtuber.cancelFetching();
if(Misc.getUriProtocol(uri) !== 'file') {
const [isYouTubeUri, videoId] = YouTube.checkYouTubeUri(uri);
if(!isYouTubeUri)
return super.set_uri(uri);
if(!this.ytClient)
this.ytClient = new YouTube.YouTubeClient();
const { root } = this.widget;
const surface = root.get_surface();
const monitor = root.display.get_monitor_at_surface(surface);
this.ytClient.getPlaybackDataAsync(videoId, monitor)
.then(data => {
this.customVideoTitle = data.title;
super.set_uri(data.uri);
if(Gtuber.mightHandleUri(uri)) {
Gtuber.parseUriPromise(uri, this)
.then(res => {
this.customVideoTitle = res.title;
super.set_uri(res.uri);
})
.catch(debug);

View File

@@ -1,6 +1,7 @@
const { Adw, GObject, Gio, Gst, Gtk } = imports.gi;
const Debug = imports.src.debug;
const Misc = imports.src.misc;
const Gtuber = imports.src.gtuber;
const { debug } = Debug;
const { settings } = Misc;
@@ -537,6 +538,7 @@ class ClapperPrefsPluginRankingSubpage extends Gtk.Box
var PrefsWindow = GObject.registerClass({
GTypeName: 'ClapperPrefsWindow',
Template: Misc.getResourceUri('ui/preferences-window.ui'),
InternalChildren: ['gtuber_group'],
},
class ClapperPrefsWindow extends Adw.PreferencesWindow
{
@@ -546,6 +548,7 @@ class ClapperPrefsWindow extends Adw.PreferencesWindow
transient_for: window,
});
this._gtuber_group.visible = Gtuber.isAvailable;
this.show();
}
});

View File

@@ -4,7 +4,6 @@ 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;
@@ -803,12 +802,10 @@ class ClapperWidget extends Gtk.Grid
{
const dropTarget = new Gtk.DropTarget({
actions: Gdk.DragAction.COPY | Gdk.DragAction.MOVE,
preload: true,
});
dropTarget.set_gtypes([GObject.TYPE_STRING]);
dropTarget.connect('motion', this._onDataMotion.bind(this));
dropTarget.connect('drop', this._onDataDrop.bind(this));
dropTarget.connect('notify::value', this._onDropValueNotify.bind(this));
return dropTarget;
}
@@ -1009,36 +1006,6 @@ 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();
const { ytClient } = this.player;
/* Speed up things by prefetching new video info before drop */
if(
!ytClient.compareLastVideoId(videoId)
&& ytClient.downloadingVideoId !== videoId
)
ytClient.getVideoInfoPromise(videoId).catch(debug);
}
_onDataMotion(dropTarget, x, y)
{
return Gdk.DragAction.MOVE;

File diff suppressed because it is too large Load Diff

View File

@@ -1,85 +0,0 @@
var QualityType = {
0: 'normal',
1: 'hfr',
};
const Itags = {
video: {
h264: {
normal: {
240: 133,
360: 134,
480: 135,
720: 136,
1080: 137,
},
hfr: {
720: 298,
1080: 299,
},
},
},
audio: {
aac: [140],
opus: [249, 250, 251],
},
combined: {
360: 18,
720: 22,
},
hls: {
240: 92,
360: 93,
480: 94,
720: 95,
1080: 96,
}
};
function _appendItagArray(arr, opts, formats)
{
const keys = Object.keys(formats);
for(let fmt of keys) {
arr.push(formats[fmt]);
if(
fmt >= opts.height
|| Math.floor(fmt * 16 / 9) >= opts.width
)
break;
}
return arr;
}
function getDashItags(opts)
{
const allowed = {
video: [],
audio: (opts.codec === 'h264')
? Itags.audio.aac
: Itags.audio.opus
};
const types = Object.keys(Itags.video[opts.codec]);
for(let type of types) {
const formats = Itags.video[opts.codec][type];
_appendItagArray(allowed.video, opts, formats);
if(type === opts.type)
break;
}
return allowed;
}
function getCombinedItags(opts)
{
return _appendItagArray([], opts, Itags.combined);
}
function getHLSItags(opts)
{
return _appendItagArray([], opts, Itags.hls);
}

View File

@@ -197,8 +197,8 @@
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="no">YouTube</property>
<object class="AdwPreferencesGroup" id="gtuber_group">
<property name="title" translatable="no">Gtuber</property>
<child>
<object class="ClapperPrefsSwitch">
<property name="title" translatable="yes">Prefer adaptive streaming</property>