refactor: rewrite search suggestions logic (#92)

* refactor: rewrite YouTube Music search suggestions

The search suggestions method can be found under `Innertube#music.getSearchSuggestions(query)`

* feat: allow `execute(..)` to return parsed data

This simplifies how response data is handled and also makes it easier for end users to write custom functionality.

* style: lint code

* chore: change a few things

* refactor: rewrite YouTube search suggestions

* chore(package): build

* chore: update type declarations

* chore: fix tests
This commit is contained in:
LuanRT
2022-07-10 17:30:20 -03:00
committed by GitHub
parent 0356dafa96
commit 8a5073b0b9
26 changed files with 196 additions and 77 deletions

View File

@@ -23,6 +23,7 @@ const EventEmitter = require('events');
const { PassThrough } = BROWSER ? require('stream-browserify') : require('stream');
const Request = require('./utils/Request');
const Constants = require('./utils/Constants');
const {
InnertubeError,
@@ -195,22 +196,27 @@ class Innertube {
/**
* Retrieves search suggestions for a given query.
*
* @param {string} query - the search query.
* @param {object} [options] - search options.
* @param {string} [options.client='YOUTUBE'] - client used to retrieve search suggestions, can be: `YOUTUBE` or `YTMUSIC`.
* @returns {Promise.<{ query: string, results: string[] }>}
*/
async getSearchSuggestions(query, options = { client: 'YOUTUBE' }) {
async getSearchSuggestions(query) {
throwIfMissing({ query });
const response = await this.actions.getSearchSuggestions(options.client, query);
if (options.client === 'YTMUSIC' && !response.data.contents) return [];
const response = await this.#request({
url: 'search',
baseURL: Constants.URLS.YT_SUGGESTIONS,
params: {
q: query,
ds: 'yt',
client: 'youtube',
xssi: 't',
oe: 'UTF',
gl: this.context.client.gl,
hl: this.context.client.hl
}
});
const suggestions = new OldParser(this, response.data, {
client: options.client,
data_type: 'SEARCH_SUGGESTIONS'
}).parse();
const data = JSON.parse(response.data.replace(')]}\'', ''));
const suggestions = data[1].map((suggestion) => suggestion[0]);
return suggestions;
}

View File

@@ -4,6 +4,7 @@ const Uuid = require('uuid');
const Proto = require('../proto');
const Utils = require('../utils/Utils');
const Constants = require('../utils/Constants');
const Parser = require('../parser/contents');
/** @namespace */
class Actions {
@@ -621,10 +622,14 @@ class Actions {
* Executes an API call.
* @param {string} action - endpoint
* @param {object} args - call arguments
* @param {boolean} [args.parse]
*/
async execute(action, args) {
const data = { ...args };
if (Reflect.has(data, 'parse'))
delete data.parse;
if (Reflect.has(data, 'request'))
delete data.request;
@@ -641,7 +646,13 @@ class Actions {
delete data.token;
}
return this.#request.post(action, data);
const response = await this.#request.post(action, data);
if (args.parse) {
return Parser.parseResponse(response.data);
}
return response;
}
#needsLogin(id) {

View File

@@ -8,7 +8,7 @@ const Library = require('../parser/ytmusic/Library');
const Artist = require('../parser/ytmusic/Artist');
const Album = require('../parser/ytmusic/Album');
const { InnertubeError, observe } = require('../utils/Utils');
const { InnertubeError, throwIfMissing, observe } = require('../utils/Utils');
/** @namespace */
class Music {
@@ -32,6 +32,7 @@ class Music {
* @returns {Promise.<Search>}
*/
async search(query, filters) {
throwIfMissing({ query });
const response = await this.#actions.search({ query, filters, client: 'YTMUSIC' });
return new Search(response, this.#actions, { is_filtered: filters?.hasOwnProperty('type') && filters.type !== 'all' });
}
@@ -73,8 +74,13 @@ class Music {
* @returns {Promise.<Artist>}
*/
async getArtist(artist_id) {
if (!artist_id.startsWith('UC')) throw new InnertubeError('Invalid artist id', artist_id);
throwIfMissing({ artist_id });
if (!artist_id.startsWith('UC'))
throw new InnertubeError('Invalid artist id', artist_id);
const response = await this.#actions.browse(artist_id, { client: 'YTMUSIC' });
return new Artist(response, this.#actions);
}
@@ -85,8 +91,13 @@ class Music {
* @returns {Promise.<Album>}
*/
async getAlbum(album_id) {
if (!album_id.startsWith('MPR')) throw new InnertubeError('Invalid album id', album_id);
throwIfMissing({ album_id });
if (!album_id.startsWith('MPR'))
throw new InnertubeError('Invalid album id', album_id);
const response = await this.#actions.browse(album_id, { client: 'YTMUSIC' });
return new Album(response, this.#actions);
}
@@ -96,6 +107,8 @@ class Music {
* @param {string} video_id
*/
async getLyrics(video_id) {
throwIfMissing({ video_id });
const response = await this.#actions.next({ video_id, client: 'YTMUSIC' });
const data = Parser.parseResponse(response.data);
@@ -123,6 +136,8 @@ class Music {
* @param {string} video_id
*/
async getUpNext(video_id) {
throwIfMissing({ video_id });
const response = await this.#actions.next({ video_id, client: 'YTMUSIC' });
const data = Parser.parseResponse(response.data);
@@ -149,6 +164,8 @@ class Music {
* @param {string} video_id
*/
async getRelated(video_id) {
throwIfMissing({ video_id });
const response = await this.#actions.next({ video_id, client: 'YTMUSIC' });
const data = Parser.parseResponse(response.data);
@@ -167,6 +184,24 @@ class Music {
info: info?.description.toString() || ''
};
}
/**
* Retrieves search suggestions for the given query.
* @param {string} query
* @returns {Promise.<import('../parser/contents/classes/SearchSuggestion')[] | import('../parser/contents/classes/HistorySuggestion')[]>}
*/
async getSearchSuggestions(query) {
const payload = {
parse: true,
input: query,
client: 'YTMUSIC'
};
const response = await this.#actions.execute('/music/get_search_suggestions', payload);
const search_suggestions_section = response.contents_memo.get('SearchSuggestionsSection')?.[0];
return search_suggestions_section?.contents || [];
}
}
module.exports = Music;

View File

@@ -0,0 +1,13 @@
'use strict';
const SearchSuggestion = require('./SearchSuggestion');
class HistorySuggestion extends SearchSuggestion {
type = 'HistorySuggestion';
constructor(data) {
super(data);
}
}
module.exports = HistorySuggestion;

View File

@@ -16,7 +16,7 @@ class MusicShelf {
this.endpoint = new NavigationEndpoint(data.bottomEndpoint);
}
if (this.continuation) {
if (data.continuations) {
this.continuation = data.continuations?.[0].nextContinuationData.continuation;
}

View File

@@ -0,0 +1,20 @@
'use strict';
const Text = require('./Text');
const NavigationEndpoint = require('./NavigationEndpoint');
class SearchSuggestion {
type = 'SearchSuggestion';
constructor(data) {
this.suggestion = new Text(data.suggestion);
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.icon_type = data.icon.iconType;
if (data.serviceEndpoint) {
this.service_endpoint = new NavigationEndpoint(data.serviceEndpoint);
}
}
}
module.exports = SearchSuggestion;

View File

@@ -0,0 +1,13 @@
'use strict';
const Parser = require('..');
class SearchSuggestionsSection {
type = 'SearchSuggestionsSection';
constructor(data) {
this.contents = Parser.parse(data.contents);
}
}
module.exports = SearchSuggestionsSection;

View File

@@ -5,14 +5,8 @@ const Parser = require('..');
class TabbedSearchResults {
type = 'TabbedSearchResults';
#data;
constructor(data) {
this.#data = data;
}
get tabs() {
return Parser.parse(this.#data.tabs);
this.tabs = Parser.parse(data.tabs);
}
}

File diff suppressed because one or more lines are too long

View File

@@ -403,8 +403,9 @@ class VideoInfo {
response.data.on('data', (chunk) => {
downloaded_size += chunk.length;
const size = (format.content_length / 1024 / 1024).toFixed(2);
const percentage = Math.floor((downloaded_size / format.content_length) * 100);
// Note: format.content_length is NOT always available so we need to use what we get from the headers.
const size = (response.headers['content-length'] / 1024 / 1024).toFixed(2);
const percentage = Math.floor((downloaded_size / response.headers['content-length']) * 100);
stream.emit('progress', {
size,
@@ -469,8 +470,9 @@ class VideoInfo {
response.data.on('data', (chunk) => {
downloaded_size += chunk.length;
const size = (format.content_length / 1024 / 1024).toFixed(2);
const percentage = Math.floor((downloaded_size / format.content_length) * 100);
// Note: format.content_length is NOT always available so we need to use what we get from the headers.
const size = (response.headers['content-length'] / 1024 / 1024).toFixed(2);
const percentage = Math.floor((downloaded_size / response.headers['content-length']) * 100);
stream.emit('progress', {
size,

View File

@@ -33,6 +33,10 @@ class HomeFeed {
const response = await this.#actions.browse(this.#continuation, { is_ctoken: true, client: 'YTMUSIC' });
return new HomeFeed(response, this.#actions);
}
get page() {
return this.#page;
}
}
module.exports = HomeFeed;