diff --git a/README.md b/README.md
index e88e8957..a71ecf0b 100644
--- a/README.md
+++ b/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.
-
(back to top)
+(back to top)
\ No newline at end of file
diff --git a/lib/Innertube.js b/lib/Innertube.js
index 154df216..89fdc2ae 100644
--- a/lib/Innertube.js
+++ b/lib/Innertube.js
@@ -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.} 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.} 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;
\ No newline at end of file
+module.exports = Innertube;
diff --git a/lib/core/Actions.js b/lib/core/Actions.js
index abe696d4..e72c7f3e 100644
--- a/lib/core/Actions.js
+++ b/lib/core/Actions.js
@@ -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 };
\ No newline at end of file
+module.exports = { engage, browse, account, playlist, flag, music, search, notifications, livechat, getVideoInfo, next, getSearchSuggestions };
\ No newline at end of file
diff --git a/lib/parser/index.js b/lib/parser/index.js
index 463c6098..d416ee53 100644
--- a/lib/parser/index.js
+++ b/lib/parser/index.js
@@ -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);