mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-18 20:12:12 +00:00
feat: full support for playlist management, closes #36
This commit is contained in:
31
README.md
31
README.md
@@ -769,23 +769,36 @@ The library makes it easy to interact with YouTube programmatically. However, do
|
||||
|
||||
* Playlists:
|
||||
```js
|
||||
// Create a playlist:
|
||||
await youtube.playlist.create('NAME', 'VIDEO_ID');
|
||||
|
||||
// Delete a playlist:
|
||||
const videos = [
|
||||
'VIDEO_ID1',
|
||||
'VIDEO_ID2',
|
||||
'VIDEO_ID3'
|
||||
//...
|
||||
];
|
||||
|
||||
// Create and delete a playlist:
|
||||
await youtube.playlist.create('My Playlist', videos);
|
||||
await youtube.playlist.delete('PLAYLIST_ID');
|
||||
|
||||
// Add videos to a playlist:
|
||||
await youtube.playlist.addVideos('PLAYLIST_ID', [ 'VIDEO_ID1', 'VIDEO_ID2' ]);
|
||||
// Add and remove videos from a playlist:
|
||||
await youtube.playlist.addVideos('PLAYLIST_ID', videos);
|
||||
await youtube.playlist.removeVideos('PLAYLIST_ID', videos);
|
||||
```
|
||||
|
||||
|
||||
* Change notification preferences:
|
||||
```js
|
||||
// Options: ALL | NONE | PERSONALIZED
|
||||
await youtube.interact.setNotificationPreferences('CHANNEL_ID', 'ALL');
|
||||
```
|
||||
|
||||
These methods will always return ```{ success: true, status_code: 200 }``` if successful.
|
||||
Response schema:
|
||||
```js
|
||||
{
|
||||
success: boolean,
|
||||
status_code: number,
|
||||
playlist_id?: string
|
||||
}
|
||||
```
|
||||
|
||||
### Account Settings
|
||||
It is also possible to manage an account's settings:
|
||||
@@ -1117,4 +1130,4 @@ Should you have any questions or concerns please contact me directly via email.
|
||||
## License
|
||||
Distributed under the [MIT](https://choosealicense.com/licenses/mit/) License.
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
@@ -222,33 +222,96 @@ class Innertube {
|
||||
*/
|
||||
setNotificationPreferences: (channel_id, type) => Actions.notifications(this, 'modify_channel_preference', { channel_id, pref: type || 'NONE' }),
|
||||
};
|
||||
|
||||
|
||||
this.playlist = {
|
||||
/**
|
||||
* Creates a playlist.
|
||||
*
|
||||
* @param {string} title
|
||||
* @param {string} video_id - Note that a video must be supplied, empty playlists cannot be created.
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
* @param {string} video_ids
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; playlist_id: string; }>}
|
||||
*/
|
||||
create: (title, video_id) => Actions.engage(this, 'playlist/create', { title, video_id }),
|
||||
|
||||
create: async (title, video_ids) => {
|
||||
const response = await Actions.playlist(this, 'playlist/create', { title, ids: video_ids });
|
||||
if (!response.success) return response;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status_code,
|
||||
playlist_id: response.data.playlistId
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes a given playlist.
|
||||
*
|
||||
* @param {string} playlist_id
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; playlist_id: string; }>}
|
||||
*/
|
||||
delete: (playlist_id) => Actions.engage(this, 'playlist/delete', { playlist_id }),
|
||||
|
||||
delete: async (playlist_id) => {
|
||||
const response = await Actions.playlist(this, 'playlist/delete', { playlist_id });
|
||||
if (!response.success) return response;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status_code,
|
||||
playlist_id
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds an array of videos to a given playlist.
|
||||
*
|
||||
* @param {string} playlist_id
|
||||
* @param {Array.<string>} video_ids
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; }>}
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; playlist_id: string; }>}
|
||||
*/
|
||||
addVideos: (playlist_id, video_ids) => Actions.engage(this, 'browse/edit_playlist', { action: 'ACTION_ADD_VIDEO', playlist_id, video_ids })
|
||||
addVideos: async (playlist_id, video_ids) => {
|
||||
const response = await Actions.playlist(this, 'browse/edit_playlist', {
|
||||
action: 'ACTION_ADD_VIDEO',
|
||||
playlist_id,
|
||||
ids: video_ids
|
||||
});
|
||||
|
||||
if (!response.success) return response;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status_code,
|
||||
playlist_id
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes videos from a given playlist.
|
||||
*
|
||||
* @param {string} playlist_id
|
||||
* @param {Array.<string>} video_ids
|
||||
* @returns {Promise.<{ success: boolean; status_code: string; playlist_id: string; }>}
|
||||
*/
|
||||
removeVideos: async (playlist_id, video_ids) => {
|
||||
const plinfo = await Actions.browse(this, 'playlist', { browse_id: `VL${playlist_id}` });
|
||||
const list = Utils.findNode(plinfo.data, 'contents', 'contents', 13, false);
|
||||
|
||||
if (!list.isEditable) throw new Utils.InnertubeError('This playlist cannot be edited.', playlist_id);
|
||||
|
||||
const videos = list.contents.filter((item) => video_ids.includes(item.playlistVideoRenderer.videoId));
|
||||
const set_video_ids = videos.map((video) => video.playlistVideoRenderer.setVideoId);
|
||||
|
||||
const response = await Actions.playlist(this, 'browse/edit_playlist', {
|
||||
action: 'ACTION_REMOVE_VIDEO',
|
||||
playlist_id,
|
||||
ids: set_video_ids
|
||||
});
|
||||
|
||||
if (!response.success) return response;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status_code,
|
||||
playlist_id
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -510,7 +573,7 @@ class Innertube {
|
||||
|
||||
return trending;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves your subscriptions feed.
|
||||
* @returns {Promise.<{ items: [{ date: string; videos: [] }] }>}
|
||||
@@ -565,7 +628,7 @@ class Innertube {
|
||||
|
||||
const lyrics_tab = Utils.findNode(continuation, 'contents', 'Lyrics', 8, false);
|
||||
|
||||
const response = await Actions.browse(this, 'lyrics', { ytmusic: true, browse_id: lyrics_tab.endpoint?.browseEndpoint.browseId });
|
||||
const response = await Actions.browse(this, 'lyrics', { browse_id: lyrics_tab.endpoint?.browseEndpoint.browseId });
|
||||
if (!response.success || !response.data?.contents?.sectionListRenderer) throw new Utils.UnavailableContentError('Lyrics not available', { video_id });
|
||||
|
||||
const lyrics = Utils.findNode(response.data, 'contents', 'runs', 6, false);
|
||||
@@ -583,7 +646,7 @@ class Innertube {
|
||||
* { title: string; description: string; total_items: number; duration: string; year: string; items: [] }>}
|
||||
*/
|
||||
async getPlaylist(playlist_id, options = { client: 'YOUTUBE' }) {
|
||||
const response = await Actions.browse(this, options.client == 'YTMUSIC' ? 'music_playlist' : 'playlist', { ytmusic: options.client == 'YTMUSIC', browse_id: `VL${playlist_id}` });
|
||||
const response = await Actions.browse(this, options.client == 'YTMUSIC' ? 'music_playlist' : 'playlist', { browse_id: `VL${playlist_id}` });
|
||||
if (!response.success) throw new Utils.InnertubeError('Could not get playlist', response);
|
||||
|
||||
const playlist = new Parser(this, response.data, {
|
||||
@@ -836,4 +899,4 @@ class Innertube {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Innertube;
|
||||
module.exports = Innertube;
|
||||
|
||||
@@ -46,20 +46,6 @@ async function engage(session, engagement_type, args = {}) {
|
||||
})[args.comment_action]();
|
||||
data.actions = [ action ];
|
||||
break;
|
||||
case 'playlist/create':
|
||||
data.title = args.title;
|
||||
data.videoIds = [args.video_id];
|
||||
break;
|
||||
case 'playlist/delete':
|
||||
data.playlistId = args.playlist_id;
|
||||
break;
|
||||
case 'browse/edit_playlist':
|
||||
data.playlistId = args.playlist_id;
|
||||
data.actions = args.video_ids.map((id) => ({
|
||||
action: args.action,
|
||||
addedVideoId: id
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid action', engagement_type);
|
||||
}
|
||||
@@ -100,6 +86,9 @@ async function browse(session, action, args = {}) {
|
||||
case 'home_feed':
|
||||
data.browseId = 'FEwhat_to_watch';
|
||||
break;
|
||||
case 'library':
|
||||
data.browseId = 'FElibrary';
|
||||
break;
|
||||
case 'trending':
|
||||
data.browseId = 'FEtrending';
|
||||
args.params && (data.params = args.params);
|
||||
@@ -107,6 +96,10 @@ async function browse(session, action, args = {}) {
|
||||
case 'subscriptions_feed':
|
||||
data.browseId = 'FEsubscriptions';
|
||||
break;
|
||||
case 'channel':
|
||||
case 'playlist':
|
||||
data.browseId = args.browse_id;
|
||||
break;
|
||||
case 'lyrics':
|
||||
case 'music_playlist':
|
||||
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
|
||||
@@ -118,10 +111,6 @@ async function browse(session, action, args = {}) {
|
||||
data.context = context;
|
||||
data.browseId = args.browse_id;
|
||||
break;
|
||||
case 'channel':
|
||||
case 'playlist':
|
||||
data.browseId = args.browse_id;
|
||||
break;
|
||||
case 'continuation':
|
||||
data.continuation = args.ctoken;
|
||||
break;
|
||||
@@ -172,6 +161,44 @@ async function flag(session, action, args = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function playlist(session, action, args = {}) {
|
||||
const data = { context: session.context };
|
||||
|
||||
switch (action) {
|
||||
case 'playlist/create':
|
||||
data.title = args.title;
|
||||
data.videoIds = args.ids;
|
||||
break;
|
||||
case 'playlist/delete':
|
||||
data.playlistId = args.playlist_id;
|
||||
break;
|
||||
case 'browse/edit_playlist':
|
||||
data.playlistId = args.playlist_id;
|
||||
data.actions = args.ids.map((id) => ({
|
||||
'ACTION_ADD_VIDEO': {
|
||||
action: args.action,
|
||||
addedVideoId: id
|
||||
},
|
||||
'ACTION_REMOVE_VIDEO': {
|
||||
action: args.action,
|
||||
setVideoId: id
|
||||
}
|
||||
})[args.action]);
|
||||
break;
|
||||
default:
|
||||
throw new Utils.InnertubeError('Invalid action', action);
|
||||
}
|
||||
|
||||
const response = await session.request.post(`/${action}`, JSON.stringify(data)).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Account settings endpoints.
|
||||
*
|
||||
@@ -458,4 +485,4 @@ async function getSearchSuggestions(session, client, input) {
|
||||
return response;
|
||||
}
|
||||
|
||||
module.exports = { engage, browse, account, flag, music, search, notifications, livechat, getVideoInfo, next, getSearchSuggestions };
|
||||
module.exports = { engage, browse, account, playlist, flag, music, search, notifications, livechat, getVideoInfo, next, getSearchSuggestions };
|
||||
@@ -28,6 +28,7 @@ class Parser {
|
||||
PLAYLIST: () => this.#processPlaylist(),
|
||||
SUBSFEED: () => this.#processSubscriptionFeed(),
|
||||
HOMEFEED: () => this.#processHomeFeed(),
|
||||
//LIBRARY: () => this.#processLibrary(), WIP
|
||||
TRENDING: () => this.#processTrending(),
|
||||
HISTORY: () => this.#processHistory(),
|
||||
COMMENTS: () => this.#processComments(),
|
||||
@@ -130,7 +131,7 @@ class Parser {
|
||||
|
||||
#processPlaylist() {
|
||||
const details = this.data.sidebar.playlistSidebarRenderer.items[0];
|
||||
|
||||
|
||||
const metadata = {
|
||||
title: this.data.metadata.playlistMetadataRenderer.title,
|
||||
description: details.playlistSidebarPrimaryInfoRenderer?.description?.simpleText || 'N/A',
|
||||
@@ -138,7 +139,7 @@ class Parser {
|
||||
last_updated: details.playlistSidebarPrimaryInfoRenderer.stats[2].runs[1]?.text || 'N/A',
|
||||
views: details.playlistSidebarPrimaryInfoRenderer.stats[1].simpleText
|
||||
}
|
||||
|
||||
|
||||
const list = Utils.findNode(this.data, 'contents', 'contents', 13, false);
|
||||
const items = YTDataItems.PlaylistItem.parse(list.contents);
|
||||
|
||||
@@ -315,6 +316,8 @@ class Parser {
|
||||
}
|
||||
}).filter((c) => c);
|
||||
|
||||
response.comment = (text) => this.session.interact.comment(text, this.args.video_id);
|
||||
|
||||
response.getContinuation = async () => {
|
||||
const continuation_item = items.find((item) => item.continuationItemRenderer);
|
||||
if (!continuation_item) throw new Utils.InnertubeError('You\'ve reached the end');
|
||||
@@ -354,6 +357,23 @@ class Parser {
|
||||
return parseItems(contents);
|
||||
}
|
||||
|
||||
// WIP
|
||||
#processLibrary() {
|
||||
const profile_data = Utils.findNode(this.data, 'contents', 'profileColumnRenderer', 3);
|
||||
|
||||
const stats = profile_data.profileColumnRenderer.items.find((item) => item.profileColumnStatsRenderer);
|
||||
const userinfo = profile_data.profileColumnRenderer.items.find((item) => item.profileColumnUserInfoRenderer);
|
||||
|
||||
const profile = {
|
||||
name: userinfo.profileColumnUserInfoRenderer?.title?.simpleText,
|
||||
thumbnails: userinfo.profileColumnUserInfoRenderer?.thumbnail.thumbnails
|
||||
}
|
||||
|
||||
return {
|
||||
profile
|
||||
}
|
||||
}
|
||||
|
||||
#processSubscriptionFeed() {
|
||||
const contents = Utils.findNode(this.data, 'contents', 'contents', 9, false);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user